refactor: split MainPage.ts into ChatHeader and OverlayManagers modules

Extract chat header builder and overlay lifecycle managers (quick
switcher, invite manager, pinned panel) from MainPage.ts (703→508 lines)
into pages/main-page/ subdirectory. Completes TODOS.md #9 (all 3 files).
This commit is contained in:
jevb
2026-03-17 02:21:03 +01:00
parent 79ea3ab42b
commit 1641824e65
3 changed files with 331 additions and 234 deletions
+40 -234
View File
@@ -19,14 +19,8 @@ import { createTypingIndicator } from "@components/TypingIndicator";
import { createServerBanner } from "@components/ServerBanner";
import type { ServerBannerControl } from "@components/ServerBanner";
import { createSettingsOverlay } from "@components/SettingsOverlay";
import { createQuickSwitcher } from "@components/QuickSwitcher";
import { createInviteManager } from "@components/InviteManager";
import type { InviteItem } from "@components/InviteManager";
import type { InviteResponse } from "@lib/types";
import { createToastContainer } from "@components/Toast";
import type { ToastContainer } from "@components/Toast";
import { createPinnedMessages } from "@components/PinnedMessages";
import type { PinnedMessage } from "@components/PinnedMessages";
import { authStore, clearAuth } from "@stores/auth.store";
import { closeSettings, toggleMemberList, uiStore } from "@stores/ui.store";
import { channelsStore, getActiveChannel, setActiveChannel } from "@stores/channels.store";
@@ -42,6 +36,12 @@ import {
isChannelLoaded,
getChannelMessages,
} from "@stores/messages.store";
import { buildChatHeader } from "./main-page/ChatHeader";
import {
createQuickSwitcherManager,
createInviteManagerController,
createPinnedPanelController,
} from "./main-page/OverlayManagers";
const log = createLogger("main-page");
@@ -76,7 +76,6 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
let messageInput: MessageInputComponent | null = null;
let typingIndicator: MountableComponent | null = null;
let chatHeaderName: HTMLSpanElement | null = null;
let chatHeaderTopic: HTMLSpanElement | null = null;
// Containers for swappable sub-components
let messagesSlot: HTMLDivElement | null = null;
@@ -92,8 +91,9 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
// Toast container for user-facing error feedback
let toast: ToastContainer | null = null;
// Pinned panel toggle — assigned inside mount(), called from buildChatHeader()
let togglePinnedPanel: () => Promise<void> = async () => {};
// Overlay controllers — created in mount()
let pinnedCtrl: ReturnType<typeof createPinnedPanelController> | null = null;
let inviteCtrl: ReturnType<typeof createInviteManagerController> | null = null;
// ---------------------------------------------------------------------------
// Helpers
@@ -144,64 +144,20 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
}
}
// ---------------------------------------------------------------------------
// Chat header (no standalone component — built inline)
// ---------------------------------------------------------------------------
function buildChatHeader(): HTMLDivElement {
const header = createElement("div", { class: "chat-header", "data-testid": "chat-header" });
const hash = createElement("span", { class: "ch-hash" }, "#");
chatHeaderName = createElement("span", { class: "ch-name", "data-testid": "chat-header-name" }, "general");
const divider = createElement("div", { class: "ch-divider" });
chatHeaderTopic = createElement("span", { class: "ch-topic" }, "");
const tools = createElement("div", { class: "ch-tools" });
const pinBtn = createElement("button", {
type: "button",
class: "pin-btn",
title: "Pins",
"aria-label": "Pins",
"data-testid": "pin-btn",
}, "\uD83D\uDCCC");
pinBtn.addEventListener("click", () => { void togglePinnedPanel(); });
const searchInput = createElement("input", {
class: "search-input",
type: "text",
placeholder: "Search...",
});
const membersToggle = createElement("button", {
type: "button",
"aria-label": "Toggle member list",
"data-testid": "members-toggle",
}, "\uD83D\uDC65");
membersToggle.addEventListener("click", () => toggleMemberList());
appendChildren(tools, searchInput, pinBtn, membersToggle);
appendChildren(header, hash, chatHeaderName, divider, chatHeaderTopic, tools);
return header;
}
// ---------------------------------------------------------------------------
// Channel switching — rebuild channel-dependent components
// ---------------------------------------------------------------------------
function mountChannelComponents(channelId: number, channelName: string): void {
// Skip if already mounted for this channel
if (currentChannelId === channelId) return;
// Tear down previous instances
destroyChannelComponents();
// Set after destroy (which resets currentChannelId to null)
currentChannelId = channelId;
// New abort controller for this channel's async work
channelAbort = new AbortController();
const signal = channelAbort.signal;
const userId = getCurrentUserId();
// Load messages from REST
void loadMessages(channelId, signal);
// MessageList
@@ -232,7 +188,7 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
});
},
onReactionClick: (msgId: number, emoji: string) => {
if (emoji === "") return; // empty = open picker (future)
if (emoji === "") return;
if (limiters.reactions.tryConsume()) {
ws.send({
type: "reaction_add",
@@ -300,11 +256,9 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
if (chatHeaderName !== null) {
setText(chatHeaderName, channelName);
}
// Topic not yet in Channel store — will be wired when available
}
function destroyChannelComponents(): void {
// Abort any in-flight fetches
if (channelAbort !== null) {
channelAbort.abort();
channelAbort = null;
@@ -328,7 +282,6 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
if (idx !== -1) children.splice(idx, 1);
messageInput = null;
}
// Clear slots
if (messagesSlot !== null) { clearChildren(messagesSlot); }
if (typingSlot !== null) { clearChildren(typingSlot); }
if (inputSlot !== null) { clearChildren(inputSlot); }
@@ -343,7 +296,6 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
function mount(target: Element): void {
container = target;
// Outer wrapper
root = createElement("div", {
style: "display:flex;flex-direction:column;height:100vh;width:100%",
});
@@ -352,7 +304,6 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
banner = createServerBanner();
root.appendChild(banner.element);
// Wire banner to WS state
unsubscribers.push(
ws.onStateChange((wsState) => {
if (banner === null) return;
@@ -364,7 +315,6 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
}),
);
// Wire banner to server_restart events
unsubscribers.push(
ws.on("server_restart", (payload) => {
if (banner !== null) {
@@ -390,7 +340,6 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
channelSidebar.mount(channelSidebarSlot);
children.push(channelSidebar);
// Move the ChannelSidebar's inner elements into our wrapper
const mountedSidebar = channelSidebarSlot.firstElementChild;
if (mountedSidebar !== null) {
while (mountedSidebar.firstChild !== null) {
@@ -399,6 +348,11 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
}
// Invite button in sidebar header
inviteCtrl = createInviteManagerController({
api,
getRoot: () => root,
getToast: () => toast,
});
const sidebarHeader = sidebarWrapper.querySelector(".channel-sidebar-header");
if (sidebarHeader !== null) {
const inviteBtn = createElement("button", {
@@ -406,12 +360,13 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
title: "Invite",
}, "Invite");
inviteBtn.addEventListener("click", () => {
void openInviteManager();
void inviteCtrl!.open();
});
sidebarHeader.appendChild(inviteBtn);
}
unsubscribers.push(() => { inviteCtrl?.cleanup(); });
// Voice widget (hidden when not in voice)
// Voice widget
const voiceWidgetSlot = createElement("div", {});
const voiceWidget = createVoiceWidget({
onDisconnect: () => {
@@ -433,9 +388,7 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
if (!limiters.voiceVideo.tryConsume()) return;
ws.send({ type: "voice_camera", payload: { enabled: false } });
},
onScreenshareToggle: () => {
// TODO: screenshare not yet in protocol
},
onScreenshareToggle: () => {},
});
voiceWidget.mount(voiceWidgetSlot);
children.push(voiceWidget);
@@ -450,7 +403,21 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
// Chat area
const chatArea = createElement("div", { class: "chat-area", "data-testid": "chat-area" });
chatArea.appendChild(buildChatHeader());
pinnedCtrl = createPinnedPanelController({
api,
getRoot: () => root,
getToast: () => toast,
getCurrentChannelId: () => currentChannelId,
});
unsubscribers.push(() => { pinnedCtrl?.cleanup(); });
const chatHeader = buildChatHeader({
onTogglePins: () => { void pinnedCtrl!.toggle(); },
onToggleMembers: () => toggleMemberList(),
});
chatHeaderName = chatHeader.refs.nameEl;
chatArea.appendChild(chatHeader.element);
messagesSlot = createElement("div", { class: "messages-slot", "data-testid": "messages-slot" });
typingSlot = createElement("div", { class: "typing-slot", "data-testid": "typing-slot" });
@@ -463,7 +430,6 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
memberList.mount(memberListSlot);
children.push(memberList);
// Wire member list visibility to uiStore
const memberListEl = memberListSlot.querySelector(".member-list");
const unsubMemberList = uiStore.subscribe((state) => {
if (memberListEl !== null) {
@@ -475,180 +441,21 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
appendChildren(app, serverStripSlot, sidebarWrapper, chatArea, memberListSlot);
root.appendChild(app);
// Settings overlay (full-screen, toggled via uiStore.settingsOpen)
// Settings overlay
const settingsOverlay = createSettingsOverlay({
onClose: () => closeSettings(),
onChangePassword: async () => { /* wired when API integration is complete */ },
onUpdateProfile: async () => { /* wired when API integration is complete */ },
onChangePassword: async () => {},
onUpdateProfile: async () => {},
onLogout: () => clearAuth(),
});
settingsOverlay.mount(root);
children.push(settingsOverlay);
// Quick switcher (Ctrl+K)
let quickSwitcher: MountableComponent | null = null;
const qsManager = createQuickSwitcherManager(() => root);
unsubscribers.push(qsManager.attach());
function openQuickSwitcher(): void {
if (quickSwitcher !== null || root === null) return;
quickSwitcher = createQuickSwitcher({
onSelectChannel: (channelId: number) => {
setActiveChannel(channelId);
},
onSearch: () => {},
onClose: closeQuickSwitcher,
});
quickSwitcher.mount(root);
}
function closeQuickSwitcher(): void {
if (quickSwitcher !== null) {
quickSwitcher.destroy?.();
quickSwitcher = null;
}
}
const quickSwitcherKeyHandler = (e: KeyboardEvent): void => {
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
e.preventDefault();
if (quickSwitcher !== null) {
closeQuickSwitcher();
} else {
openQuickSwitcher();
}
}
};
document.addEventListener("keydown", quickSwitcherKeyHandler);
unsubscribers.push(() => {
document.removeEventListener("keydown", quickSwitcherKeyHandler);
closeQuickSwitcher();
});
// Invite manager overlay
let inviteManager: MountableComponent | null = null;
function closeInviteManager(): void {
if (inviteManager !== null) {
inviteManager.destroy?.();
inviteManager = null;
}
}
function mapInviteResponse(r: InviteResponse): InviteItem {
// Server may include extra fields (e.g. created_by) beyond the typed response
const extra = r as unknown as Record<string, unknown>;
const createdBy = typeof extra["created_by"] === "object"
&& extra["created_by"] !== null
? (extra["created_by"] as { username?: string }).username ?? "unknown"
: "unknown";
const uses = r.use_count
?? (typeof extra["uses"] === "number" ? (extra["uses"] as number) : 0);
return {
code: r.code,
createdBy,
createdAt: r.expires_at ?? "",
uses,
maxUses: r.max_uses,
expiresAt: r.expires_at,
};
}
async function openInviteManager(): Promise<void> {
if (inviteManager !== null || root === null) return;
try {
const raw = await api.getInvites();
const invites = raw.map(mapInviteResponse);
inviteManager = createInviteManager({
invites,
onCreateInvite: async () => {
const created = await api.createInvite({});
return mapInviteResponse(created);
},
onRevokeInvite: async (code: string) => {
const raw2 = await api.getInvites();
const match = raw2.find((i) => i.code === code);
if (match !== undefined) {
await api.revokeInvite(match.id);
}
},
onCopyLink: (code: string) => {
void navigator.clipboard.writeText(code);
},
onClose: closeInviteManager,
});
if (root !== null) {
inviteManager.mount(root);
}
} catch (err) {
log.error("Failed to open invite manager", { error: String(err) });
toast?.show("Failed to load invites", "error");
}
}
unsubscribers.push(() => {
closeInviteManager();
});
// Pinned messages panel
let pinnedPanel: MountableComponent | null = null;
function closePinnedPanel(): void {
if (pinnedPanel !== null) {
pinnedPanel.destroy?.();
pinnedPanel = null;
}
}
function mapToPinnedMessage(msg: {
readonly id: number;
readonly user: { readonly username: string };
readonly content: string;
readonly created_at?: string;
readonly timestamp?: string;
}): PinnedMessage {
return {
id: msg.id,
author: msg.user.username,
content: msg.content,
timestamp: msg.created_at ?? msg.timestamp ?? "",
};
}
togglePinnedPanel = async (): Promise<void> => {
if (pinnedPanel !== null) {
closePinnedPanel();
return;
}
if (root === null || currentChannelId === null) return;
const channelId = currentChannelId;
try {
const resp = await api.getPins(channelId);
const pins = resp.messages.map(mapToPinnedMessage);
pinnedPanel = createPinnedMessages({
channelId,
pinnedMessages: pins,
onJumpToMessage: (_msgId: number) => {
closePinnedPanel();
},
onUnpin: (msgId: number) => {
void api.unpinMessage(channelId, msgId);
closePinnedPanel();
},
onClose: closePinnedPanel,
});
if (root !== null) {
pinnedPanel.mount(root);
}
} catch (err) {
log.error("Failed to load pinned messages", { error: String(err) });
toast?.show("Failed to load pinned messages", "error");
}
};
unsubscribers.push(() => {
closePinnedPanel();
});
// Toast container for error feedback
// Toast container
toast = createToastContainer();
toast.mount(root);
children.push(toast);
@@ -664,7 +471,6 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
});
unsubscribers.push(unsubChannels);
// Mount for current active channel if any
const active = getActiveChannel();
if (active !== null) {
mountChannelComponents(active.id, active.name);
@@ -0,0 +1,59 @@
/**
* ChatHeader — builds the channel header bar with name, topic, pins, search,
* and member-list toggle.
*/
import { createElement, appendChildren } from "@lib/dom";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ChatHeaderRefs {
readonly nameEl: HTMLSpanElement;
readonly topicEl: HTMLSpanElement;
}
export interface ChatHeaderOptions {
readonly onTogglePins: () => void;
readonly onToggleMembers: () => void;
}
// ---------------------------------------------------------------------------
// Builder
// ---------------------------------------------------------------------------
export function buildChatHeader(
opts: ChatHeaderOptions,
): { element: HTMLDivElement; refs: ChatHeaderRefs } {
const header = createElement("div", { class: "chat-header", "data-testid": "chat-header" });
const hash = createElement("span", { class: "ch-hash" }, "#");
const nameEl = createElement("span", { class: "ch-name", "data-testid": "chat-header-name" }, "general");
const divider = createElement("div", { class: "ch-divider" });
const topicEl = createElement("span", { class: "ch-topic" }, "");
const tools = createElement("div", { class: "ch-tools" });
const pinBtn = createElement("button", {
type: "button",
class: "pin-btn",
title: "Pins",
"aria-label": "Pins",
"data-testid": "pin-btn",
}, "\uD83D\uDCCC");
pinBtn.addEventListener("click", () => { opts.onTogglePins(); });
const searchInput = createElement("input", {
class: "search-input",
type: "text",
placeholder: "Search...",
});
const membersToggle = createElement("button", {
type: "button",
"aria-label": "Toggle member list",
"data-testid": "members-toggle",
}, "\uD83D\uDC65");
membersToggle.addEventListener("click", () => opts.onToggleMembers());
appendChildren(tools, searchInput, pinBtn, membersToggle);
appendChildren(header, hash, nameEl, divider, topicEl, tools);
return { element: header, refs: { nameEl, topicEl } };
}
@@ -0,0 +1,232 @@
/**
* Overlay managers — quick switcher, invite manager, and pinned messages panel.
* Each factory returns an open/toggle + cleanup pair for use in MainPage.
*/
import type { MountableComponent } from "@lib/safe-render";
import type { ApiClient } from "@lib/api";
import { createLogger } from "@lib/logger";
import { createQuickSwitcher } from "@components/QuickSwitcher";
import { createInviteManager } from "@components/InviteManager";
import type { InviteItem } from "@components/InviteManager";
import type { InviteResponse } from "@lib/types";
import { createPinnedMessages } from "@components/PinnedMessages";
import type { PinnedMessage } from "@components/PinnedMessages";
import type { ToastContainer } from "@components/Toast";
import { setActiveChannel } from "@stores/channels.store";
const log = createLogger("overlays");
// ---------------------------------------------------------------------------
// Invite response mapping
// ---------------------------------------------------------------------------
export function mapInviteResponse(r: InviteResponse): InviteItem {
const extra = r as unknown as Record<string, unknown>;
const createdBy = typeof extra["created_by"] === "object"
&& extra["created_by"] !== null
? (extra["created_by"] as { username?: string }).username ?? "unknown"
: "unknown";
const uses = r.use_count
?? (typeof extra["uses"] === "number" ? (extra["uses"] as number) : 0);
return {
code: r.code,
createdBy,
createdAt: r.expires_at ?? "",
uses,
maxUses: r.max_uses,
expiresAt: r.expires_at,
};
}
// ---------------------------------------------------------------------------
// Pinned message mapping
// ---------------------------------------------------------------------------
export function mapToPinnedMessage(msg: {
readonly id: number;
readonly user: { readonly username: string };
readonly content: string;
readonly created_at?: string;
readonly timestamp?: string;
}): PinnedMessage {
return {
id: msg.id,
author: msg.user.username,
content: msg.content,
timestamp: msg.created_at ?? msg.timestamp ?? "",
};
}
// ---------------------------------------------------------------------------
// Quick Switcher Manager
// ---------------------------------------------------------------------------
export interface QuickSwitcherManager {
/** Attach Ctrl+K handler; returns cleanup function. */
attach(): () => void;
}
export function createQuickSwitcherManager(
getRoot: () => HTMLDivElement | null,
): QuickSwitcherManager {
let instance: MountableComponent | null = null;
function open(): void {
const root = getRoot();
if (instance !== null || root === null) return;
instance = createQuickSwitcher({
onSelectChannel: (channelId: number) => {
setActiveChannel(channelId);
},
onSearch: () => {},
onClose: close,
});
instance.mount(root);
}
function close(): void {
if (instance !== null) {
instance.destroy?.();
instance = null;
}
}
function attach(): () => void {
const handler = (e: KeyboardEvent): void => {
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
e.preventDefault();
if (instance !== null) {
close();
} else {
open();
}
}
};
document.addEventListener("keydown", handler);
return () => {
document.removeEventListener("keydown", handler);
close();
};
}
return { attach };
}
// ---------------------------------------------------------------------------
// Invite Manager Controller
// ---------------------------------------------------------------------------
export interface InviteManagerController {
open(): Promise<void>;
cleanup(): void;
}
export function createInviteManagerController(opts: {
readonly api: ApiClient;
readonly getRoot: () => HTMLDivElement | null;
readonly getToast: () => ToastContainer | null;
}): InviteManagerController {
let instance: MountableComponent | null = null;
function close(): void {
if (instance !== null) {
instance.destroy?.();
instance = null;
}
}
async function open(): Promise<void> {
const root = opts.getRoot();
if (instance !== null || root === null) return;
try {
const raw = await opts.api.getInvites();
const invites = raw.map(mapInviteResponse);
instance = createInviteManager({
invites,
onCreateInvite: async () => {
const created = await opts.api.createInvite({});
return mapInviteResponse(created);
},
onRevokeInvite: async (code: string) => {
const raw2 = await opts.api.getInvites();
const match = raw2.find((i) => i.code === code);
if (match !== undefined) {
await opts.api.revokeInvite(match.id);
}
},
onCopyLink: (code: string) => {
void navigator.clipboard.writeText(code);
},
onClose: close,
});
if (root !== null) {
instance.mount(root);
}
} catch (err) {
log.error("Failed to open invite manager", { error: String(err) });
opts.getToast()?.show("Failed to load invites", "error");
}
}
return { open, cleanup: close };
}
// ---------------------------------------------------------------------------
// Pinned Panel Controller
// ---------------------------------------------------------------------------
export interface PinnedPanelController {
toggle(): Promise<void>;
cleanup(): void;
}
export function createPinnedPanelController(opts: {
readonly api: ApiClient;
readonly getRoot: () => HTMLDivElement | null;
readonly getToast: () => ToastContainer | null;
readonly getCurrentChannelId: () => number | null;
}): PinnedPanelController {
let instance: MountableComponent | null = null;
function close(): void {
if (instance !== null) {
instance.destroy?.();
instance = null;
}
}
async function toggle(): Promise<void> {
if (instance !== null) {
close();
return;
}
const root = opts.getRoot();
const channelId = opts.getCurrentChannelId();
if (root === null || channelId === null) return;
try {
const resp = await opts.api.getPins(channelId);
const pins = resp.messages.map(mapToPinnedMessage);
instance = createPinnedMessages({
channelId,
pinnedMessages: pins,
onJumpToMessage: (_msgId: number) => {
close();
},
onUnpin: (msgId: number) => {
void opts.api.unpinMessage(channelId, msgId);
close();
},
onClose: close,
});
if (root !== null) {
instance.mount(root);
}
} catch (err) {
log.error("Failed to load pinned messages", { error: String(err) });
opts.getToast()?.show("Failed to load pinned messages", "error");
}
}
return { toggle, cleanup: close };
}