mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user