From 1641824e65e8ed0ec783deb5286f4131900dfc97 Mon Sep 17 00:00:00 2001 From: jevb Date: Tue, 17 Mar 2026 02:21:03 +0100 Subject: [PATCH] refactor: split MainPage.ts into ChatHeader and OverlayManagers modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- Client/tauri-client/src/pages/MainPage.ts | 274 +++--------------- .../src/pages/main-page/ChatHeader.ts | 59 ++++ .../src/pages/main-page/OverlayManagers.ts | 232 +++++++++++++++ 3 files changed, 331 insertions(+), 234 deletions(-) create mode 100644 Client/tauri-client/src/pages/main-page/ChatHeader.ts create mode 100644 Client/tauri-client/src/pages/main-page/OverlayManagers.ts diff --git a/Client/tauri-client/src/pages/MainPage.ts b/Client/tauri-client/src/pages/MainPage.ts index 4c7e4fb5..8e415ed6 100644 --- a/Client/tauri-client/src/pages/MainPage.ts +++ b/Client/tauri-client/src/pages/MainPage.ts @@ -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 = async () => {}; + // Overlay controllers — created in mount() + let pinnedCtrl: ReturnType | null = null; + let inviteCtrl: ReturnType | 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; - 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 { - 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 => { - 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); diff --git a/Client/tauri-client/src/pages/main-page/ChatHeader.ts b/Client/tauri-client/src/pages/main-page/ChatHeader.ts new file mode 100644 index 00000000..18a77dcf --- /dev/null +++ b/Client/tauri-client/src/pages/main-page/ChatHeader.ts @@ -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 } }; +} diff --git a/Client/tauri-client/src/pages/main-page/OverlayManagers.ts b/Client/tauri-client/src/pages/main-page/OverlayManagers.ts new file mode 100644 index 00000000..6a56002b --- /dev/null +++ b/Client/tauri-client/src/pages/main-page/OverlayManagers.ts @@ -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; + 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; + 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 { + 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; + 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 { + 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 }; +}