diff --git a/package-lock.json b/package-lock.json index 5d5a0131..c216c3d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,9 @@ "emoji-picker-react": "^4.13.3", "exifr": "^7.1.3", "gh-pages": "^6.3.0", + "highlight.js": "^11.11.1", "html-react-parser": "^5.2.7", + "immer": "^10.2.0", "marked": "^16.4.0", "react": "^18.3.1", "react-color": "^2.19.3", @@ -45,7 +47,6 @@ "@vitest/ui": "^3.1.2", "autoprefixer": "^10.4.20", "globals": "^15.14.0", - "highlight.js": "^11.11.1", "jsdom": "^26.1.0", "lefthook": "^1.11.10", "postcss": "^8.5.1", @@ -4106,7 +4107,6 @@ "version": "11.11.1", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=12.0.0" @@ -4225,6 +4225,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", diff --git a/package.json b/package.json index b14a8026..0e74b3cc 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "gh-pages": "^6.3.0", "highlight.js": "^11.11.1", "html-react-parser": "^5.2.7", + "immer": "^10.2.0", "marked": "^16.4.0", "react": "^18.3.1", "react-color": "^2.19.3", diff --git a/src/App.tsx b/src/App.tsx index 786ce01c..3fa9a111 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import type React from "react"; import { useEffect, useState } from "react"; import AppLayout from "./components/layout/AppLayout"; import { ServerNoticesPopup } from "./components/message/ServerNoticesPopup"; +import { ModalStackProvider } from "./components/modals"; import AddServerModal from "./components/ui/AddServerModal"; import ChannelListModal from "./components/ui/ChannelListModal"; import ChannelRenameModal from "./components/ui/ChannelRenameModal"; @@ -29,10 +30,7 @@ const askPermissions = async () => { }; const initializeEnvSettings = ( - toggleAddServerModal: ( - isOpen?: boolean, - prefillDetails?: ConnectionDetails | null, - ) => void, + openModal: (modalId: string, props?: unknown) => void, joinChannel: (serverId: string, channelName: string) => void, ) => { if (loadSavedServers().length > 0) return; @@ -47,7 +45,7 @@ const initializeEnvSettings = ( } if (!__DEFAULT_IRC_SERVER_NAME__) { } - toggleAddServerModal(true, { + openModal("addServer", { name: __DEFAULT_IRC_SERVER_NAME__ || "Obsidian IRC", host, port, @@ -68,19 +66,9 @@ const initializeEnvSettings = ( const App: React.FC = () => { const { - toggleAddServerModal, - toggleEditServerModal, - ui: { - isAddServerModalOpen, - isUserProfileModalOpen, - isChannelListModalOpen, - isChannelRenameModalOpen, - isServerNoticesPopupOpen, - isEditServerModalOpen, - editServerId, - linkSecurityWarnings, - profileViewRequest, - }, + openModal, + closeModal, + ui: { isServerNoticesPopupOpen, linkSecurityWarnings, profileViewRequest }, joinChannel, connectToSavedServers, toggleServerNoticesPopup, @@ -138,47 +126,44 @@ const App: React.FC = () => { // askPermissions(); useEffect(() => { - initializeEnvSettings(toggleAddServerModal, joinChannel); + initializeEnvSettings(openModal, joinChannel); // Auto-reconnect to saved servers on app startup connectToSavedServers(); }, [ - toggleAddServerModal, + openModal, joinChannel, // Auto-reconnect to saved servers on app startup connectToSavedServers, ]); // Removed connectToSavedServers from dependencies return ( -
- - {isAddServerModalOpen && } - {isEditServerModalOpen && editServerId && ( - toggleEditServerModal(false)} - /> - )} - {isUserProfileModalOpen && } - {isChannelListModalOpen && } - {isChannelRenameModalOpen && } - - {userProfileModalState?.isOpen && ( - setUserProfileModalState(null)} - serverId={userProfileModalState.serverId} - username={userProfileModalState.username} - /> - )} - {isServerNoticesPopupOpen && ( - toggleServerNoticesPopup(false)} - onUsernameContextMenu={handleUsernameContextMenu} - onIrcLinkClick={handleIrcLinkClick} - joinChannel={joinChannel} - /> - )} -
+ +
+ + + + + + + + {userProfileModalState?.isOpen && ( + setUserProfileModalState(null)} + serverId={userProfileModalState.serverId} + username={userProfileModalState.username} + /> + )} + {isServerNoticesPopupOpen && ( + toggleServerNoticesPopup(false)} + onUsernameContextMenu={handleUsernameContextMenu} + onIrcLinkClick={handleIrcLinkClick} + joinChannel={joinChannel} + /> + )} +
+
); }; diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index 12eac11d..300f9e18 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -2,7 +2,7 @@ import { platform } from "@tauri-apps/plugin-os"; import type React from "react"; import { useEffect } from "react"; import { useMediaQuery } from "../../hooks/useMediaQuery"; -import useStore from "../../store"; +import useStore, { type layoutColumn } from "../../store"; import { GlobalNotifications } from "../ui/GlobalNotifications"; import { ChannelList } from "./ChannelList"; import { ChatArea } from "./ChatArea"; diff --git a/src/components/layout/ChannelList.tsx b/src/components/layout/ChannelList.tsx index 09ae7b48..0dda3732 100644 --- a/src/components/layout/ChannelList.tsx +++ b/src/components/layout/ChannelList.tsx @@ -38,7 +38,7 @@ export const ChannelList: React.FC<{ pinPrivateChat, unpinPrivateChat, reorderPrivateChats, - toggleUserProfileModal, + openModal, setMobileViewActiveColumn, reorderChannels, } = useStore(); @@ -1276,7 +1276,7 @@ export const ChannelList: React.FC<{
toggleUserProfileModal(true)} + onClick={() => openModal("settings")} >
@@ -1341,7 +1341,7 @@ export const ChannelList: React.FC<{ diff --git a/src/components/layout/ChatArea.tsx b/src/components/layout/ChatArea.tsx index 390766fc..adf38c1c 100644 --- a/src/components/layout/ChatArea.tsx +++ b/src/components/layout/ChatArea.tsx @@ -169,7 +169,7 @@ export const ChatArea: React.FC<{ const selectPrivateChat = useStore((state) => state.selectPrivateChat); const connect = useStore((state) => state.connect); const joinChannel = useStore((state) => state.joinChannel); - const toggleAddServerModal = useStore((state) => state.toggleAddServerModal); + const openModal = useStore((state) => state.openModal); const redactMessage = useStore((state) => state.redactMessage); const warnUser = useStore((state) => state.warnUser); const kickUser = useStore((state) => state.kickUser); @@ -182,15 +182,10 @@ export const ChatArea: React.FC<{ selectedPrivateChatId: null, }; const { selectedChannelId, selectedPrivateChatId } = currentSelection; - const { - isMemberListVisible, - isSettingsModalOpen, - isUserProfileModalOpen, - isAddServerModalOpen, - isChannelListModalOpen, - isChannelRenameModalOpen, - isServerNoticesPopupOpen, - } = ui; + const { isMemberListVisible, isServerNoticesPopupOpen } = ui; + + // Check if settings modal is open via modal manager + const isSettingsModalOpen = ui.modals.settings?.isOpen || false; const isMobile = useMediaQuery("(max-width: 768px)"); @@ -274,7 +269,7 @@ export const ChatArea: React.FC<{ const parsed = parseIrcUrl(rawUrl, currentUser?.username || "user"); // Open the connect modal with pre-filled server details - toggleAddServerModal(true, { + openModal("addServer", { name: parsed.host, host: parsed.host, port: parsed.port.toString(), @@ -1409,23 +1404,19 @@ export const ChatArea: React.FC<{ if ("__TAURI__" in window && ["android", "ios"].includes(platform())) return; // Don't steal focus if any modal is open - if ( + const isAnyModalOpen = isSettingsModalOpen || - isUserProfileModalOpen || - isAddServerModalOpen || - isChannelListModalOpen || - isChannelRenameModalOpen - ) - return; + ui.modals.addServer?.isOpen || + ui.modals.editServer?.isOpen || + ui.modals.channelList?.isOpen || + ui.modals.channelRename?.isOpen; + if (isAnyModalOpen) return; inputRef.current?.focus(); }, [ selectedChannelId, selectedPrivateChatId, isSettingsModalOpen, - isUserProfileModalOpen, - isAddServerModalOpen, - isChannelListModalOpen, - isChannelRenameModalOpen, + ui.modals, ]); return ( diff --git a/src/components/layout/ChatHeader.tsx b/src/components/layout/ChatHeader.tsx index 01511a1d..c441c326 100644 --- a/src/components/layout/ChatHeader.tsx +++ b/src/components/layout/ChatHeader.tsx @@ -67,13 +67,8 @@ export const ChatHeader: React.FC = ({ onOpenChannelSettings, onOpenInviteUser, }) => { - const { - toggleChannelListModal, - toggleChannelRenameModal, - toggleMemberList, - pinPrivateChat, - unpinPrivateChat, - } = useStore(); + const { openModal, toggleMemberList, pinPrivateChat, unpinPrivateChat } = + useStore(); const [isEditingTopic, setIsEditingTopic] = useState(false); const [editedTopic, setEditedTopic] = useState(""); const [avatarLoadFailed, setAvatarLoadFailed] = useState(false); @@ -584,7 +579,7 @@ export const ChatHeader: React.FC = ({ )} + )} +
+ ); +} diff --git a/src/components/modals/base/index.ts b/src/components/modals/base/index.ts new file mode 100644 index 00000000..4a521ce6 --- /dev/null +++ b/src/components/modals/base/index.ts @@ -0,0 +1,4 @@ +export { Modal, type ModalProps } from "./Modal"; +export { ModalBody, type ModalBodyProps } from "./ModalBody"; +export { ModalFooter, type ModalFooterProps } from "./ModalFooter"; +export { ModalHeader, type ModalHeaderProps } from "./ModalHeader"; diff --git a/src/components/modals/context/ModalStackContext.tsx b/src/components/modals/context/ModalStackContext.tsx new file mode 100644 index 00000000..cee86405 --- /dev/null +++ b/src/components/modals/context/ModalStackContext.tsx @@ -0,0 +1,137 @@ +import type React from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react"; + +interface ModalInfo { + id: string; + preventClose: boolean; + zIndex: number; +} + +interface ModalStackContextValue { + registerModal: (id: string, preventClose: boolean) => number; + unregisterModal: (id: string) => void; + isTopmost: (id: string) => boolean; + canModalClose: (id: string) => boolean; + getModalZIndex: (id: string) => number; + getStackSize: () => number; +} + +const ModalStackContext = createContext(null); + +export function ModalStackProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [modalStack, setModalStack] = useState([]); + const stackRef = useRef([]); + + // Keep ref in sync with state for immediate access + useEffect(() => { + stackRef.current = modalStack; + }, [modalStack]); + + const registerModal = useCallback( + (id: string, preventClose: boolean): number => { + let zIndex = 50; + + setModalStack((prev) => { + // Check if already registered + if (prev.some((m) => m.id === id)) { + return prev; + } + + // Calculate z-index + zIndex = 50 + prev.length * 10; + + const newStack = [...prev, { id, preventClose, zIndex }]; + return newStack; + }); + + return zIndex; + }, + [], + ); + + const unregisterModal = useCallback((id: string) => { + setModalStack((prev) => prev.filter((m) => m.id !== id)); + }, []); + + const isTopmost = useCallback((id: string): boolean => { + const stack = stackRef.current; + if (stack.length === 0) return false; + return stack[stack.length - 1].id === id; + }, []); + + const canModalClose = useCallback((id: string): boolean => { + const stack = stackRef.current; + const modalIndex = stack.findIndex((m) => m.id === id); + + if (modalIndex === -1) return false; + + // Modal can close if: + // 1. It's the topmost modal + // 2. No modals above it are blocking + const isTop = modalIndex === stack.length - 1; + + return isTop; + }, []); + + const getModalZIndex = useCallback((id: string): number => { + const stack = stackRef.current; + const modal = stack.find((m) => m.id === id); + return modal?.zIndex ?? 50; + }, []); + + const getStackSize = useCallback((): number => { + return stackRef.current.length; + }, []); + + return ( + + {children} + + ); +} + +// Fallback implementation for when no provider is available (e.g., in tests) +const fallbackContext: ModalStackContextValue = { + registerModal: () => 50, + unregisterModal: () => {}, + isTopmost: () => true, + canModalClose: () => true, + getModalZIndex: () => 50, + getStackSize: () => 0, +}; + +export function useModalStackContext() { + const context = useContext(ModalStackContext); + + // Provide fallback for testing - in production, the provider should always be present + if (!context) { + // If in development/production without provider, warn but provide fallback + if (import.meta.env?.DEV) { + console.warn( + "useModalStackContext: No ModalStackProvider found, using fallback", + ); + } + return fallbackContext; + } + return context; +} diff --git a/src/components/modals/hooks/index.ts b/src/components/modals/hooks/index.ts new file mode 100644 index 00000000..88f950b3 --- /dev/null +++ b/src/components/modals/hooks/index.ts @@ -0,0 +1,5 @@ +export { useClickOutside } from "./useClickOutside"; +export { useModalEscape } from "./useModalEscape"; +export { useModalStack } from "./useModalStack"; +export { useScrollLock } from "./useScrollLock"; +export { useUnsavedChanges } from "./useUnsavedChanges"; diff --git a/src/components/modals/hooks/useClickOutside.ts b/src/components/modals/hooks/useClickOutside.ts new file mode 100644 index 00000000..4bdf2f53 --- /dev/null +++ b/src/components/modals/hooks/useClickOutside.ts @@ -0,0 +1,29 @@ +import { type RefObject, useEffect } from "react"; + +/** + * Hook to handle clicks outside of a ref element + * @param ref - Ref to the element to detect clicks outside of + * @param onClickOutside - Callback when clicking outside + * @param enabled - Whether click-outside handling is enabled (default: true) + * @param canClose - Whether this modal can close (from modal stack) + */ +export function useClickOutside( + ref: RefObject, + onClickOutside: () => void, + enabled = true, + canClose = true, +) { + useEffect(() => { + if (!enabled || !canClose) return; + + const handleClick = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + onClickOutside(); + } + }; + + // Use mousedown instead of click to catch events before they bubble + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [ref, onClickOutside, enabled, canClose]); +} diff --git a/src/components/modals/hooks/useModalEscape.ts b/src/components/modals/hooks/useModalEscape.ts new file mode 100644 index 00000000..9157ea5b --- /dev/null +++ b/src/components/modals/hooks/useModalEscape.ts @@ -0,0 +1,31 @@ +import { useEffect } from "react"; + +/** + * Hook to handle ESC key press for closing modals + * @param isOpen - Whether the modal is currently open + * @param onClose - Callback to close the modal + * @param enabled - Whether ESC handling is enabled (default: true) + * @param canClose - Whether this modal can close (from modal stack) + */ +export function useModalEscape( + isOpen: boolean, + onClose: () => void, + enabled = true, + canClose = true, +) { + useEffect(() => { + if (!isOpen || !enabled || !canClose) return; + + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + onClose(); + } + }; + + // Use capture phase to ensure we get the event before input fields + document.addEventListener("keydown", handleEscape, true); + return () => document.removeEventListener("keydown", handleEscape, true); + }, [isOpen, onClose, enabled, canClose]); +} diff --git a/src/components/modals/hooks/useModalStack.ts b/src/components/modals/hooks/useModalStack.ts new file mode 100644 index 00000000..3d48d9a1 --- /dev/null +++ b/src/components/modals/hooks/useModalStack.ts @@ -0,0 +1,79 @@ +import { useEffect, useRef } from "react"; + +// Global modal stack to track open modals +let modalStack: number[] = []; +let nextModalId = 0; + +/** + * Hook to manage modal stacking with proper z-index and close prevention + * @param isOpen - Whether the modal is currently open + * @param preventClose - Whether this modal blocks closing (default: false) + * @returns Object with modalId, zIndex, and isTopmost flag + */ +export function useModalStack(isOpen: boolean, preventClose = false) { + const modalIdRef = useRef(null); + const preventCloseRef = useRef(preventClose); + + // Update preventClose ref when it changes + preventCloseRef.current = preventClose; + + useEffect(() => { + if (isOpen) { + // Assign modal ID on open if not already assigned + if (modalIdRef.current === null) { + modalIdRef.current = nextModalId++; + modalStack.push(modalIdRef.current); + } + } else { + // Remove from stack on close + if (modalIdRef.current !== null) { + modalStack = modalStack.filter((id) => id !== modalIdRef.current); + modalIdRef.current = null; + } + } + + return () => { + // Cleanup on unmount + if (modalIdRef.current !== null) { + modalStack = modalStack.filter((id) => id !== modalIdRef.current); + } + }; + }, [isOpen]); + + // Calculate z-index based on position in stack + const modalId = modalIdRef.current; + const stackIndex = modalId !== null ? modalStack.indexOf(modalId) : -1; + const zIndex = stackIndex >= 0 ? 50 + stackIndex * 10 : 50; + const isTopmost = + modalId !== null && modalStack[modalStack.length - 1] === modalId; + + // Check if any modal above this one is blocking + const isBlockedByParent = () => { + if (modalId === null || stackIndex === -1) return false; + + // If there are any modals higher in the stack, we're blocked + return stackIndex < modalStack.length - 1; + }; + + return { + modalId, + zIndex, + isTopmost, + isBlocked: isBlockedByParent(), + canClose: isTopmost && !isBlockedByParent(), + }; +} + +/** + * Check if there are any blocking modals open + */ +export function hasBlockingModal(): boolean { + return modalStack.length > 0; +} + +/** + * Get the current modal stack for debugging + */ +export function getModalStack(): number[] { + return [...modalStack]; +} diff --git a/src/components/modals/hooks/useScrollLock.ts b/src/components/modals/hooks/useScrollLock.ts new file mode 100644 index 00000000..ee2c9d9e --- /dev/null +++ b/src/components/modals/hooks/useScrollLock.ts @@ -0,0 +1,31 @@ +import { useEffect } from "react"; + +/** + * Hook to lock body scroll when modal is open + * @param isLocked - Whether scroll should be locked + */ +export function useScrollLock(isLocked: boolean) { + useEffect(() => { + if (!isLocked) return; + + // Store original values + const originalOverflow = document.body.style.overflow; + const originalPaddingRight = document.body.style.paddingRight; + + // Get scrollbar width to prevent layout shift + const scrollbarWidth = + window.innerWidth - document.documentElement.clientWidth; + + // Lock scroll and compensate for scrollbar + document.body.style.overflow = "hidden"; + if (scrollbarWidth > 0) { + document.body.style.paddingRight = `${scrollbarWidth}px`; + } + + return () => { + // Restore original values + document.body.style.overflow = originalOverflow; + document.body.style.paddingRight = originalPaddingRight; + }; + }, [isLocked]); +} diff --git a/src/components/modals/hooks/useUnsavedChanges.ts b/src/components/modals/hooks/useUnsavedChanges.ts new file mode 100644 index 00000000..e2610d79 --- /dev/null +++ b/src/components/modals/hooks/useUnsavedChanges.ts @@ -0,0 +1,104 @@ +import { useEffect, useRef, useState } from "react"; + +/** + * Deep equality check for objects and arrays + */ +function deepEqual(obj1: unknown, obj2: unknown): boolean { + if (obj1 === obj2) return true; + + if ( + typeof obj1 !== "object" || + typeof obj2 !== "object" || + obj1 === null || + obj2 === null + ) { + return false; + } + + const keys1 = Object.keys(obj1 as object); + const keys2 = Object.keys(obj2 as object); + + if (keys1.length !== keys2.length) return false; + + for (const key of keys1) { + const val1 = (obj1 as Record)[key]; + const val2 = (obj2 as Record)[key]; + + const areObjects = isObject(val1) && isObject(val2); + if ( + (areObjects && !deepEqual(val1, val2)) || + (!areObjects && val1 !== val2) + ) { + return false; + } + } + + return true; +} + +function isObject(obj: unknown): obj is Record { + return obj !== null && typeof obj === "object" && !Array.isArray(obj); +} + +/** + * Hook to track unsaved changes in forms/settings + * @param currentValues - Current form values + * @param isOpen - Whether the modal/form is open (resets original values when opened) + * @returns Object with hasChanges boolean and reset function + * + * @example + * ```tsx + * const { hasChanges, reset } = useUnsavedChanges( + * { name, email, age }, + * isModalOpen + * ); + * + * + * + * ``` + */ +export function useUnsavedChanges>( + currentValues: T, + isOpen: boolean, +) { + const [originalValues, setOriginalValues] = useState(null); + const isFirstOpen = useRef(true); + + // Store original values when modal first opens + useEffect(() => { + if (isOpen && isFirstOpen.current) { + // Delay capturing original values to ensure all initialization is complete + const timeoutId = setTimeout(() => { + setOriginalValues(structuredClone(currentValues)); + }, 0); + isFirstOpen.current = false; + return () => clearTimeout(timeoutId); + } + + // Reset on close + if (!isOpen) { + isFirstOpen.current = true; + setOriginalValues(null); + } + }, [isOpen, currentValues]); + + // Check if current values differ from original + const hasChanges = originalValues + ? !deepEqual(currentValues, originalValues) + : false; + + // Reset current values to original + const reset = () => { + if (originalValues) { + // This doesn't actually reset the values, just the tracking + // The component using this hook needs to handle the actual reset + setOriginalValues(structuredClone(currentValues)); + } + }; + + return { + hasChanges, + reset, + originalValues, + }; +} diff --git a/src/components/modals/index.ts b/src/components/modals/index.ts new file mode 100644 index 00000000..ec2454d1 --- /dev/null +++ b/src/components/modals/index.ts @@ -0,0 +1,32 @@ +// Base components +export { + Modal, + ModalBody, + type ModalBodyProps, + ModalFooter, + type ModalFooterProps, + ModalHeader, + type ModalHeaderProps, + type ModalProps, +} from "./base"; +// Context +export { + ModalStackProvider, + useModalStackContext, +} from "./context/ModalStackContext"; +// Hooks +export { + useClickOutside, + useModalEscape, + useScrollLock, + useUnsavedChanges, +} from "./hooks"; +// Layout components +export { + ListModal, + type ListModalProps, + ModalWithSidebar, + type ModalWithSidebarProps, + SimpleModal, + type SimpleModalProps, +} from "./layouts"; diff --git a/src/components/modals/layouts/ListModal.tsx b/src/components/modals/layouts/ListModal.tsx new file mode 100644 index 00000000..7f14f3ab --- /dev/null +++ b/src/components/modals/layouts/ListModal.tsx @@ -0,0 +1,139 @@ +import type React from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { FaSearch } from "react-icons/fa"; +import { Modal, ModalBody, ModalFooter, ModalHeader } from "../base"; + +export interface ListModalProps { + /** Whether the modal is open */ + isOpen: boolean; + /** Callback when modal should close */ + onClose: () => void; + /** Modal title */ + title: string | React.ReactNode; + /** Items to display */ + items: T[]; + /** Function to render each item */ + renderItem: (item: T, index: number) => React.ReactNode; + /** Function to extract unique key from item (required for proper React rendering) */ + getKey: (item: T, index: number) => string | number; + /** Function to extract search text from item */ + getSearchText?: (item: T) => string; + /** Whether search is enabled (default: true) */ + searchable?: boolean; + /** Search input placeholder */ + searchPlaceholder?: string; + /** Message when no items match filter */ + emptyMessage?: string; + /** Optional footer content */ + footer?: React.ReactNode; + /** Maximum width (default: '2xl') */ + maxWidth?: "md" | "lg" | "xl" | "2xl" | "3xl"; + /** Custom filter function (overrides default search) */ + filterFunction?: (item: T, searchQuery: string) => boolean; +} + +/** + * List modal with search functionality + */ +export function ListModal({ + isOpen, + onClose, + title, + items, + renderItem, + getKey, + getSearchText, + searchable = true, + searchPlaceholder = "Search...", + emptyMessage = "No items found", + footer, + maxWidth = "2xl", + filterFunction, +}: ListModalProps) { + const [search, setSearch] = useState(""); + const searchInputRef = useRef(null); + + // Auto-focus search input when modal opens + useEffect(() => { + if (isOpen && searchable && searchInputRef.current) { + // Small delay to ensure modal is rendered + setTimeout(() => searchInputRef.current?.focus(), 100); + } + }, [isOpen, searchable]); + + // Reset search when modal closes + useEffect(() => { + if (!isOpen) { + setSearch(""); + } + }, [isOpen]); + + const filteredItems = useMemo(() => { + if (!searchable || !search.trim()) return items; + + const query = search.toLowerCase(); + + if (filterFunction) { + return items.filter((item) => filterFunction(item, query)); + } + + if (getSearchText) { + return items.filter((item) => + getSearchText(item).toLowerCase().includes(query), + ); + } + + // Fallback: try to search in string representation + return items.filter((item) => String(item).toLowerCase().includes(query)); + }, [items, search, searchable, getSearchText, filterFunction]); + + const widthClass = { + md: "max-w-md", + lg: "max-w-lg", + xl: "max-w-xl", + "2xl": "max-w-2xl", + "3xl": "max-w-3xl", + }[maxWidth]; + + return ( + + + + + {searchable && ( +
+ + setSearch(e.target.value)} + className="w-full bg-discord-dark-400 border border-discord-dark-500 rounded px-10 py-2 text-white placeholder-discord-text-muted focus:outline-none focus:border-discord-primary" + /> +
+ )} + +
+ {filteredItems.length === 0 ? ( +
+ {emptyMessage} +
+ ) : ( +
+ {filteredItems.map((item, index) => ( +
{renderItem(item, index)}
+ ))} +
+ )} +
+
+ + {footer && {footer}} +
+ ); +} diff --git a/src/components/modals/layouts/ModalWithSidebar.tsx b/src/components/modals/layouts/ModalWithSidebar.tsx new file mode 100644 index 00000000..605ac8db --- /dev/null +++ b/src/components/modals/layouts/ModalWithSidebar.tsx @@ -0,0 +1,81 @@ +import type React from "react"; +import { Modal, ModalBody, ModalFooter, ModalHeader } from "../base"; + +export interface ModalWithSidebarProps { + /** Whether the modal is open */ + isOpen: boolean; + /** Callback when modal should close */ + onClose: () => void; + /** Modal title */ + title: string | React.ReactNode; + /** Sidebar content */ + sidebar: React.ReactNode; + /** Main content */ + children: React.ReactNode; + /** Optional footer content */ + footer?: React.ReactNode; + /** Maximum width (default: '4xl') */ + maxWidth?: "2xl" | "3xl" | "4xl" | "5xl" | "6xl" | "7xl"; + /** Sidebar width (default: '250px') */ + sidebarWidth?: string; + /** Whether to show close button (default: true) */ + showClose?: boolean; + /** Whether ESC closes modal (default: true) */ + closeOnEscape?: boolean; + /** Whether clicking backdrop closes modal (default: true) */ + closeOnBackdrop?: boolean; + /** Custom footer justification */ + footerJustify?: "start" | "end" | "center" | "between"; +} + +/** + * Modal with sidebar navigation - used for complex settings modals + */ +export function ModalWithSidebar({ + isOpen, + onClose, + title, + sidebar, + children, + footer, + maxWidth = "4xl", + sidebarWidth = "250px", + showClose = true, + closeOnEscape = true, + closeOnBackdrop = true, + footerJustify = "end", +}: ModalWithSidebarProps) { + const widthClass = { + "2xl": "max-w-2xl", + "3xl": "max-w-3xl", + "4xl": "max-w-4xl", + "5xl": "max-w-5xl", + "6xl": "max-w-6xl", + "7xl": "max-w-7xl", + }[maxWidth]; + + return ( + + {/* Sidebar */} +
+ {sidebar} +
+ + {/* Main content */} +
+ + {children} + {footer && {footer}} +
+
+ ); +} diff --git a/src/components/modals/layouts/SimpleModal.tsx b/src/components/modals/layouts/SimpleModal.tsx new file mode 100644 index 00000000..a6a35cd8 --- /dev/null +++ b/src/components/modals/layouts/SimpleModal.tsx @@ -0,0 +1,77 @@ +import type React from "react"; +import { Modal, ModalBody, ModalFooter, ModalHeader } from "../base"; + +export interface SimpleModalProps { + /** Whether the modal is open */ + isOpen: boolean; + /** Callback when modal should close */ + onClose: () => void; + /** Modal title */ + title: string | React.ReactNode; + /** Modal content */ + children: React.ReactNode; + /** Optional footer content */ + footer?: React.ReactNode; + /** Maximum width (default: 'md') */ + maxWidth?: "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl"; + /** Whether to show close button (default: true) */ + showClose?: boolean; + /** Whether ESC closes modal (default: true) */ + closeOnEscape?: boolean; + /** Whether clicking backdrop closes modal (default: true) */ + closeOnBackdrop?: boolean; + /** Prevent closing (for warnings) */ + preventClose?: boolean; + /** Optional icon for header */ + icon?: React.ReactNode; + /** Custom footer justification */ + footerJustify?: "start" | "end" | "center" | "between"; +} + +/** + * Simple centered modal layout - most common modal pattern + */ +export function SimpleModal({ + isOpen, + onClose, + title, + children, + footer, + maxWidth = "md", + showClose = true, + closeOnEscape = true, + closeOnBackdrop = true, + preventClose = false, + icon, + footerJustify = "end", +}: SimpleModalProps) { + const widthClass = { + sm: "max-w-sm", + md: "max-w-md", + lg: "max-w-lg", + xl: "max-w-xl", + "2xl": "max-w-2xl", + "3xl": "max-w-3xl", + "4xl": "max-w-4xl", + }[maxWidth]; + + return ( + + + {children} + {footer && {footer}} + + ); +} diff --git a/src/components/modals/layouts/index.ts b/src/components/modals/layouts/index.ts new file mode 100644 index 00000000..8ae6babf --- /dev/null +++ b/src/components/modals/layouts/index.ts @@ -0,0 +1,6 @@ +export { ListModal, type ListModalProps } from "./ListModal"; +export { + ModalWithSidebar, + type ModalWithSidebarProps, +} from "./ModalWithSidebar"; +export { SimpleModal, type SimpleModalProps } from "./SimpleModal"; diff --git a/src/components/modals/shared/SettingField.tsx b/src/components/modals/shared/SettingField.tsx new file mode 100644 index 00000000..040cdbd9 --- /dev/null +++ b/src/components/modals/shared/SettingField.tsx @@ -0,0 +1,27 @@ +import type React from "react"; + +/** + * Shared SettingField component used in modal settings interfaces + * Provides consistent layout for settings with label, description, and input control + */ +export interface SettingFieldProps { + label: string; + description: string; + children: React.ReactNode; +} + +export const SettingField: React.FC = ({ + label, + description, + children, +}) => ( +
+
+ +

{description}

+
+ {children} +
+); diff --git a/src/components/modals/shared/UnsavedChangesModal.tsx b/src/components/modals/shared/UnsavedChangesModal.tsx new file mode 100644 index 00000000..39e8c355 --- /dev/null +++ b/src/components/modals/shared/UnsavedChangesModal.tsx @@ -0,0 +1,61 @@ +import type React from "react"; +import { Modal } from "../base/Modal"; + +/** + * Shared modal component for warning users about unsaved changes + * Used when navigating away or closing modals with unsaved data + */ +export interface UnsavedChangesModalProps { + isOpen: boolean; + onCancel: () => void; + onDontSave: () => void; + onSave: () => void; + title?: string; + message?: string; + /** Whether to prevent closing by clicking outside or ESC (default: true for blocking behavior) */ + preventClose?: boolean; +} + +export const UnsavedChangesModal: React.FC = ({ + isOpen, + onCancel, + onDontSave, + onSave, + title = "Unsaved Changes", + message = "You have unsaved changes. Would you like to save them?", + preventClose = true, +}) => { + return ( + +
+

{title}

+

{message}

+
+ + + +
+
+
+ ); +}; diff --git a/src/components/modals/shared/index.ts b/src/components/modals/shared/index.ts new file mode 100644 index 00000000..5e713e9c --- /dev/null +++ b/src/components/modals/shared/index.ts @@ -0,0 +1,8 @@ +/** + * Shared modal components used across different settings modals + */ + +export type { SettingFieldProps } from "./SettingField"; +export { SettingField } from "./SettingField"; +export type { UnsavedChangesModalProps } from "./UnsavedChangesModal"; +export { UnsavedChangesModal } from "./UnsavedChangesModal"; diff --git a/src/components/ui/AddPrivateChatModal.tsx b/src/components/ui/AddPrivateChatModal.tsx index 66d9e8f8..4359ada7 100644 --- a/src/components/ui/AddPrivateChatModal.tsx +++ b/src/components/ui/AddPrivateChatModal.tsx @@ -1,8 +1,9 @@ import type React from "react"; -import { useMemo, useState } from "react"; -import { FaSearch, FaTimes, FaUser } from "react-icons/fa"; +import { useMemo } from "react"; +import { FaUser } from "react-icons/fa"; import ircClient from "../../lib/ircClient"; import useStore from "../../store"; +import { ListModal } from "../modals"; interface AddPrivateChatModalProps { isOpen: boolean; @@ -16,7 +17,6 @@ export const AddPrivateChatModal: React.FC = ({ serverId, }) => { const { openPrivateChat, selectPrivateChat, servers } = useStore(); - const [searchTerm, setSearchTerm] = useState(""); const availableUsers = useMemo(() => { // Get users from the store instead of ircClient directly @@ -36,18 +36,10 @@ export const AddPrivateChatModal: React.FC = ({ } const allUsersArray = Array.from(allUsers.values()); - const filteredUsers = allUsersArray.filter( + return allUsersArray.filter( (user) => user.username !== currentUser?.username, ); - - if (!searchTerm.trim()) { - return filteredUsers; - } - - return filteredUsers.filter((user) => - user.username.toLowerCase().includes(searchTerm.toLowerCase()), - ); - }, [serverId, searchTerm, servers]); + }, [serverId, servers]); const handleUserSelect = (username: string) => { openPrivateChat(serverId, username); @@ -59,79 +51,47 @@ export const AddPrivateChatModal: React.FC = ({ if (privateChat) { selectPrivateChat(privateChat.id); } - setSearchTerm(""); onClose(); }; - if (!isOpen) return null; - - return ( -
-
- {/* Header */} -
-

- Start Private Message -

- -
- - {/* Search Input */} -
- - setSearchTerm(e.target.value)} - className="w-full bg-discord-dark-400 border border-discord-dark-500 rounded px-10 py-2 text-white placeholder-discord-channels-default focus:outline-none focus:border-discord-primary" - autoFocus - /> -
+ // Render function for each user item + const renderUserItem = (user: (typeof availableUsers)[0]) => ( + + ); - {/* User List */} -
- {availableUsers.length === 0 ? ( -
- {searchTerm - ? "No users found matching your search" - : "No users available"} -
- ) : ( -
- {availableUsers.map((user) => ( - - ))} -
- )} -
+ // Footer content + const footerContent = ( + + ); - {/* Footer */} -
- -
-
-
+ return ( + user.id} + getSearchText={(user) => user.username} + searchPlaceholder="Search users..." + emptyMessage="No users available" + footer={footerContent} + maxWidth="md" + /> ); }; diff --git a/src/components/ui/AddServerModal.tsx b/src/components/ui/AddServerModal.tsx index ba092aa3..c29948dc 100644 --- a/src/components/ui/AddServerModal.tsx +++ b/src/components/ui/AddServerModal.tsx @@ -1,16 +1,29 @@ import type React from "react"; -import { useState } from "react"; -import { FaQuestionCircle, FaTimes } from "react-icons/fa"; +import { useEffect, useState } from "react"; +import { FaQuestionCircle } from "react-icons/fa"; import useStore from "../../store"; +import { SimpleModal } from "../modals"; export const AddServerModal: React.FC = () => { - const { - toggleAddServerModal, - connect, - isConnecting, - connectionError, - ui: { prefillServerDetails }, - } = useStore(); + const { closeModal, connect, isConnecting, connectionError, ui } = useStore(); + + // Read prefill data from modal props (passed via openModal) or fallback to legacy prefillServerDetails + const modalProps = ui.modals.addServer?.props as + | { + name?: string; + host?: string; + port?: string; + nickname?: string; + ui?: { + disableServerConnectionInfo?: boolean; + hideServerInfo?: boolean; + title?: string; + hideClose?: boolean; + }; + } + | undefined; + const prefillServerDetails = modalProps || ui.prefillServerDetails; + const isOpen = ui.modals.addServer?.isOpen || false; const [serverName, setServerName] = useState( prefillServerDetails?.name || "", @@ -36,6 +49,19 @@ export const AddServerModal: React.FC = () => { const [error, setError] = useState(""); + // Update state when prefillServerDetails changes (e.g., when clicking a server in discovery) + useEffect(() => { + if (prefillServerDetails) { + setServerName(prefillServerDetails.name || ""); + setServerHost(prefillServerDetails.host || ""); + setServerPort(prefillServerDetails.port || "443"); + setNickname( + prefillServerDetails.nickname || + `user${Math.floor(Math.random() * 1000)}`, + ); + } + }, [prefillServerDetails]); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(""); @@ -80,7 +106,7 @@ export const AddServerModal: React.FC = () => { registerEmail, registerPassword, ); - toggleAddServerModal(false); + closeModal("addServer"); } catch (err) { const errorMessage = err instanceof Error ? err.message : "Unknown error occurred"; @@ -92,24 +118,41 @@ export const AddServerModal: React.FC = () => { prefillServerDetails?.ui?.disableServerConnectionInfo; const hideServerInfo = prefillServerDetails?.ui?.hideServerInfo; - return ( -
-
-
-

- {prefillServerDetails?.ui?.title || "Add IRC Server"} -

- {!prefillServerDetails?.ui?.hideClose && ( - - )} -
+ const modalTitle = prefillServerDetails?.ui?.title || "Add IRC Server"; -
+ const footerContent = ( +
+ {!prefillServerDetails?.ui?.hideClose && ( + + )} + +
+ ); + + return ( + closeModal("addServer")} + title={modalTitle} + footer={footerContent} + maxWidth="md" + showClose={!prefillServerDetails?.ui?.hideClose} + > +
+ {!hideServerInfo && ( <>
@@ -333,28 +376,9 @@ export const AddServerModal: React.FC = () => { {error || connectionError}
)} - -
- {!prefillServerDetails?.ui?.hideClose && ( - - )} - -
-
+ ); }; diff --git a/src/components/ui/ChannelListModal.tsx b/src/components/ui/ChannelListModal.tsx index f4361074..c09fd7b4 100644 --- a/src/components/ui/ChannelListModal.tsx +++ b/src/components/ui/ChannelListModal.tsx @@ -1,24 +1,28 @@ import type React from "react"; import { useCallback, useEffect, useRef, useState } from "react"; -import { FaTimes, FaUsers } from "react-icons/fa"; +import { FaUsers } from "react-icons/fa"; import ircClient from "../../lib/ircClient"; import { getChannelAvatarUrl, getChannelDisplayName } from "../../lib/ircUtils"; import useStore from "../../store"; +import { SimpleModal } from "../modals"; const ChannelListModal: React.FC = () => { const { servers, - ui: { selectedServerId }, + ui, channelList, channelMetadataCache, listingInProgress, channelListFilters, listChannels, updateChannelListFilters, - toggleChannelListModal, + closeModal, joinChannel, } = useStore(); + const { selectedServerId } = ui; + const isOpen = ui.modals.channelList?.isOpen || false; + const selectedServer = servers.find((s) => s.id === selectedServerId); const elist = (selectedServer?.elist || "").toUpperCase(); const rawChannels = selectedServerId @@ -173,10 +177,10 @@ const ChannelListModal: React.FC = () => { }, []); useEffect(() => { - if (selectedServerId) { + if (selectedServerId && isOpen) { listChannels(selectedServerId); } - }, [selectedServerId, listChannels]); + }, [selectedServerId, isOpen, listChannels]); // Sync filter state with store useEffect(() => { @@ -253,7 +257,7 @@ const ChannelListModal: React.FC = () => { const handleJoinChannel = (channelName: string) => { if (selectedServerId) { joinChannel(selectedServerId, channelName); - toggleChannelListModal(false); // Optionally close modal after joining + closeModal("channelList"); // Close modal after joining } }; @@ -268,346 +272,319 @@ const ChannelListModal: React.FC = () => { } }; + const modalTitle = `Channels on ${selectedServer?.networkName || selectedServer?.name || "Unknown Network"}`; + return ( -
toggleChannelListModal(false)} + closeModal("channelList")} + title={modalTitle} + maxWidth="2xl" > -
e.stopPropagation()} - > -
-

- Channels on{" "} - {selectedServer?.networkName || - selectedServer?.name || - "Unknown Network"} -

- -
- -
- - Total: {filteredChannels.length} - -
+
+ + Total: {filteredChannels.length} + +
-
- setFilter(e.target.value)} - className="flex-1 bg-discord-dark-300 text-white px-3 py-2 rounded" - /> - -
+
+ setFilter(e.target.value)} + className="flex-1 bg-discord-dark-300 text-white px-3 py-2 rounded" + /> + +
- {/* Advanced Filters */} -
- - - {showFilters && ( -
-
- {/* User Count Filtering (U extension) */} - {elist.includes("U") && ( -
-
- - - setMinUsers(Number.parseInt(e.target.value, 10) || 0) - } - className="w-full bg-discord-dark-400 text-white px-2 py-1 rounded text-sm" - placeholder="0" - /> -
-
- - - setMaxUsers(Number.parseInt(e.target.value, 10) || 0) - } - className="w-full bg-discord-dark-400 text-white px-2 py-1 rounded text-sm" - placeholder="0" - /> -
-
- )} - - {/* Creation Time Filtering (C extension) */} - {elist.includes("C") && ( -
-
- - - setMinCreationTime( - Number.parseInt(e.target.value, 10) || 0, - ) - } - className="w-full bg-discord-dark-400 text-white px-2 py-1 rounded text-sm" - placeholder="0" - /> -
-
- - - setMaxCreationTime( - Number.parseInt(e.target.value, 10) || 0, - ) - } - className="w-full bg-discord-dark-400 text-white px-2 py-1 rounded text-sm" - placeholder="0" - /> -
+ {/* Advanced Filters */} +
+ + + {showFilters && ( +
+
+ {/* User Count Filtering (U extension) */} + {elist.includes("U") && ( +
+
+ + + setMinUsers(Number.parseInt(e.target.value, 10) || 0) + } + className="w-full bg-discord-dark-400 text-white px-2 py-1 rounded text-sm" + placeholder="0" + />
- )} - - {/* Topic Time Filtering (T extension) */} - {elist.includes("T") && ( -
-
- - - setMinTopicTime( - Number.parseInt(e.target.value, 10) || 0, - ) - } - className="w-full bg-discord-dark-400 text-white px-2 py-1 rounded text-sm" - placeholder="0" - /> -
-
- - - setMaxTopicTime( - Number.parseInt(e.target.value, 10) || 0, - ) - } - className="w-full bg-discord-dark-400 text-white px-2 py-1 rounded text-sm" - placeholder="0" - /> -
+
+ + + setMaxUsers(Number.parseInt(e.target.value, 10) || 0) + } + className="w-full bg-discord-dark-400 text-white px-2 py-1 rounded text-sm" + placeholder="0" + />
- )} +
+ )} - {/* Mask Filtering (M extension) */} - {elist.includes("M") && ( + {/* Creation Time Filtering (C extension) */} + {elist.includes("C") && ( +
setMask(e.target.value)} + type="number" + min="0" + value={minCreationTime} + onChange={(e) => + setMinCreationTime( + Number.parseInt(e.target.value, 10) || 0, + ) + } className="w-full bg-discord-dark-400 text-white px-2 py-1 rounded text-sm" - placeholder="*channel*" + placeholder="0" />
- )} - - {/* Non-matching Mask Filtering (N extension) */} - {elist.includes("N") && (
setNotMask(e.target.value)} + type="number" + min="0" + value={maxCreationTime} + onChange={(e) => + setMaxCreationTime( + Number.parseInt(e.target.value, 10) || 0, + ) + } className="w-full bg-discord-dark-400 text-white px-2 py-1 rounded text-sm" - placeholder="*spam*" + placeholder="0" />
- )} +
+ )} - {elist.length === 0 && ( -
- Server doesn't support advanced LIST filtering + {/* Topic Time Filtering (T extension) */} + {elist.includes("T") && ( +
+
+ + + setMinTopicTime( + Number.parseInt(e.target.value, 10) || 0, + ) + } + className="w-full bg-discord-dark-400 text-white px-2 py-1 rounded text-sm" + placeholder="0" + />
- )} -
+
+ + + setMaxTopicTime( + Number.parseInt(e.target.value, 10) || 0, + ) + } + className="w-full bg-discord-dark-400 text-white px-2 py-1 rounded text-sm" + placeholder="0" + /> +
+
+ )} - + {/* Mask Filtering (M extension) */} + {elist.includes("M") && ( +
+ + setMask(e.target.value)} + className="w-full bg-discord-dark-400 text-white px-2 py-1 rounded text-sm" + placeholder="*channel*" + /> +
+ )} + + {/* Non-matching Mask Filtering (N extension) */} + {elist.includes("N") && ( +
+ + setNotMask(e.target.value)} + className="w-full bg-discord-dark-400 text-white px-2 py-1 rounded text-sm" + placeholder="*spam*" + /> +
+ )} + + {elist.length === 0 && ( +
+ Server doesn't support advanced LIST filtering +
+ )}
- )} -
- {selectedServerId && listingInProgress[selectedServerId] && ( -

- Loading channels... -

+ +
)} +
-
-
- {filteredChannels.length === 0 && - !(selectedServerId && listingInProgress[selectedServerId]) && ( -

No channels found.

- )} - {filteredChannels - .slice(0, displayedChannelsCount) - .map((channel) => { - const metadata = metadataCache[channel.channel]; - const avatarUrl = metadata?.avatar - ? getChannelAvatarUrl( - { - avatar: { - value: metadata.avatar, - visibility: "public", - }, - }, - 32, - ) - : null; - const displayName = metadata?.displayName; - const hasMetadata = !!(avatarUrl || displayName); - - return ( -
setChannelRef(channel.channel, el)} - data-channel={channel.channel} - className="bg-discord-dark-300 p-3 rounded flex justify-between items-center cursor-pointer hover:bg-discord-dark-400" - onClick={() => handleJoinChannel(channel.channel)} - > -
- {/* Channel icon */} -
- {avatarUrl ? ( - {channel.channel} { - // Fallback to # icon if image fails to load - e.currentTarget.style.display = "none"; - const fallback = e.currentTarget - .nextElementSibling as HTMLElement; - if (fallback) fallback.style.display = "block"; - }} - /> - ) : null} - - # - -
- - {/* Channel name and topic */} -
-
- - {displayName || - getChannelDisplayName(channel.channel, {})} - - {hasMetadata && - displayName && - displayName !== channel.channel.substring(1) && ( - - {channel.channel} - - )} -
-

- {channel.topic || "No topic"} -

-
-
+ {selectedServerId && listingInProgress[selectedServerId] && ( +

Loading channels...

+ )} - - - {channel.userCount} +
+
+ {filteredChannels.length === 0 && + !(selectedServerId && listingInProgress[selectedServerId]) && ( +

No channels found.

+ )} + {filteredChannels.slice(0, displayedChannelsCount).map((channel) => { + const metadata = metadataCache[channel.channel]; + const avatarUrl = metadata?.avatar + ? getChannelAvatarUrl( + { + avatar: { + value: metadata.avatar, + visibility: "public", + }, + }, + 32, + ) + : null; + const displayName = metadata?.displayName; + const hasMetadata = !!(avatarUrl || displayName); + + return ( +
setChannelRef(channel.channel, el)} + data-channel={channel.channel} + className="bg-discord-dark-300 p-3 rounded flex justify-between items-center cursor-pointer hover:bg-discord-dark-400" + onClick={() => handleJoinChannel(channel.channel)} + > +
+ {/* Channel icon */} +
+ {avatarUrl ? ( + {channel.channel} { + // Fallback to # icon if image fails to load + e.currentTarget.style.display = "none"; + const fallback = e.currentTarget + .nextElementSibling as HTMLElement; + if (fallback) fallback.style.display = "block"; + }} + /> + ) : null} + + #
- ); - })} - {loadingMore && ( -
-

- Loading more channels... -

-
- )} - {displayedChannelsCount < filteredChannels.length && - !loadingMore && ( -
-

- Showing {displayedChannelsCount} of{" "} - {filteredChannels.length} channels -

+ + {/* Channel name and topic */} +
+
+ + {displayName || + getChannelDisplayName(channel.channel, {})} + + {hasMetadata && + displayName && + displayName !== channel.channel.substring(1) && ( + + {channel.channel} + + )} +
+

+ {channel.topic || "No topic"} +

+
- )} -
+ + + + {channel.userCount} + +
+ ); + })} + {loadingMore && ( +
+

Loading more channels...

+
+ )} + {displayedChannelsCount < filteredChannels.length && !loadingMore && ( +
+

+ Showing {displayedChannelsCount} of {filteredChannels.length}{" "} + channels +

+
+ )}
-
+ ); }; diff --git a/src/components/ui/ChannelRenameModal.tsx b/src/components/ui/ChannelRenameModal.tsx index 717cfc0e..acec5e6b 100644 --- a/src/components/ui/ChannelRenameModal.tsx +++ b/src/components/ui/ChannelRenameModal.tsx @@ -1,10 +1,10 @@ import type React from "react"; import { useState } from "react"; -import { FaTimes } from "react-icons/fa"; import useStore from "../../store"; +import { SimpleModal } from "../modals"; const ChannelRenameModal: React.FC = () => { - const { servers, ui, renameChannel, toggleChannelRenameModal } = useStore(); + const { servers, ui, renameChannel, closeModal } = useStore(); const selectedServerId = ui.selectedServerId; const currentSelection = selectedServerId @@ -34,68 +34,66 @@ const ChannelRenameModal: React.FC = () => { newName.trim(), reason.trim() || undefined, ); - toggleChannelRenameModal(false); + closeModal("channelRename"); } }; + const isOpen = ui.modals.channelRename?.isOpen || false; + if (!selectedChannel) return null; + const footerContent = ( + + ); + return ( -
-
-
-

Rename Channel

- + closeModal("channelRename")} + title="Rename Channel" + footer={footerContent} + maxWidth="md" + > +
+
+ +
-
-
- - -
- -
- - setNewName(e.target.value)} - className="w-full p-2 bg-discord-dark-300 text-white rounded" - placeholder="Enter new channel name" - /> -
- -
- - setReason(e.target.value)} - className="w-full p-2 bg-discord-dark-300 text-white rounded" - placeholder="Reason for renaming" - /> -
+
+ + setNewName(e.target.value)} + className="w-full p-2 bg-discord-dark-300 text-white rounded" + placeholder="Enter new channel name" + /> +
- +
+ + setReason(e.target.value)} + className="w-full p-2 bg-discord-dark-300 text-white rounded" + placeholder="Reason for renaming" + />
-
+
); }; diff --git a/src/components/ui/ChannelSettingsModal.tsx b/src/components/ui/ChannelSettingsModal.tsx index 591cd179..d8566455 100644 --- a/src/components/ui/ChannelSettingsModal.tsx +++ b/src/components/ui/ChannelSettingsModal.tsx @@ -547,12 +547,7 @@ const ChannelSettingsModal: React.FC = ({ // Apply avatar change if (channelAvatar !== (channel?.metadata?.avatar?.value || "")) { - await metadataSet( - serverId, - channelName, - "avatar", - channelAvatar || undefined, - ); + await metadataSet(serverId, channelName, "avatar", channelAvatar || ""); } // Apply display name change @@ -564,7 +559,7 @@ const ChannelSettingsModal: React.FC = ({ serverId, channelName, "display-name", - channelDisplayName || undefined, + channelDisplayName || "", ); } } finally { diff --git a/src/components/ui/EditServerModal.tsx b/src/components/ui/EditServerModal.tsx index 26e98c50..f3e65a43 100644 --- a/src/components/ui/EditServerModal.tsx +++ b/src/components/ui/EditServerModal.tsx @@ -1,19 +1,19 @@ import type React from "react"; import { useState } from "react"; -import { FaQuestionCircle, FaTimes } from "react-icons/fa"; +import { FaQuestionCircle } from "react-icons/fa"; import useStore, { loadSavedServers } from "../../store"; -import type { ServerConfig } from "../../types"; +import type { Server } from "../../types"; +import { SimpleModal } from "../modals"; -interface EditServerModalProps { - serverId: string; - onClose: () => void; -} +export const EditServerModal: React.FC = () => { + const { servers, updateServer, sendRaw, isConnecting, closeModal, ui } = + useStore(); -export const EditServerModal: React.FC = ({ - serverId, - onClose, -}) => { - const { servers, updateServer, sendRaw, isConnecting } = useStore(); + const isOpen = ui.modals.editServer?.isOpen || false; + const serverId = (ui.modals.editServer?.props as { serverId?: string }) + ?.serverId; + + if (!isOpen || !serverId) return null; const server = servers.find((s) => s.id === serverId); const savedServers = loadSavedServers(); @@ -87,7 +87,7 @@ export const EditServerModal: React.FC = ({ try { // Update server configuration - const updatedConfig: Partial = { + const updatedConfig = { name: finalServerName, host: serverHost.trim(), port: Number.parseInt(serverPort, 10), @@ -108,10 +108,10 @@ export const EditServerModal: React.FC = ({ } : {}), operOnConnect, - }; + } as unknown as Partial; updateServer(serverId, updatedConfig); - onClose(); + closeModal("editServer"); } catch (err) { const errorMessage = err instanceof Error ? err.message : "Unknown error occurred"; @@ -127,20 +127,36 @@ export const EditServerModal: React.FC = ({ } }; + const footerContent = ( +
+ + +
+ ); + return ( -
-
-
-

Edit Server

- -
- -
+ closeModal("editServer")} + title="Edit Server" + footer={footerContent} + maxWidth="md" + > +
+
-
+
); }; diff --git a/src/components/ui/ExternalLinkWarningModal.tsx b/src/components/ui/ExternalLinkWarningModal.tsx index 1da0d9d2..b4359423 100644 --- a/src/components/ui/ExternalLinkWarningModal.tsx +++ b/src/components/ui/ExternalLinkWarningModal.tsx @@ -1,6 +1,6 @@ import type React from "react"; -import { createPortal } from "react-dom"; import { FaExclamationTriangle, FaExternalLinkAlt } from "react-icons/fa"; +import { SimpleModal } from "../modals"; interface ExternalLinkWarningModalProps { isOpen: boolean; @@ -17,79 +17,63 @@ const ExternalLinkWarningModal: React.FC = ({ }) => { if (!isOpen) return null; - const handleBackdropClick = (e: React.MouseEvent) => { - if (e.target === e.currentTarget) { - onCancel(); - } - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Escape") { - onCancel(); - } - }; - // Truncate very long URLs for display const displayUrl = url.length > 80 ? `${url.substring(0, 80)}...` : url; - return createPortal( -
-
- {/* Header */} -
-
- -

- External Link Warning -

-
-
+ const modalTitle = ( +
+ + External Link Warning +
+ ); - {/* Content */} -
-

- You are about to open an external link: -

+ const footerContent = ( + <> + + + + ); -
- {displayUrl} -
+ return ( + +
+

+ You are about to open an external link: +

-
-

- ⚠️ Be careful! Only open links from trusted - sources. Malicious links can compromise your security or privacy. -

-
+
+ {displayUrl} +
-

- Do you want to open this link in a new tab? +

+

+ ⚠️ Be careful! Only open links from trusted sources. + Malicious links can compromise your security or privacy.

- {/* Actions */} -
- - -
+

+ Do you want to open this link in a new tab? +

-
, - document.body, + ); }; diff --git a/src/components/ui/HomeScreen.tsx b/src/components/ui/HomeScreen.tsx index b7ab0f51..403c54a8 100644 --- a/src/components/ui/HomeScreen.tsx +++ b/src/components/ui/HomeScreen.tsx @@ -3,8 +3,7 @@ import { FaPlus } from "react-icons/fa"; import useStore from "../../store"; const DiscoverGrid = () => { - const { toggleAddServerModal, connect, isConnecting, connectionError } = - useStore(); + const { openModal, connect, isConnecting, connectionError } = useStore(); const [query, setQuery] = useState(""); const [servers, setServers] = useState< { name: string; description: string; server?: string; port?: string }[] @@ -36,7 +35,7 @@ const DiscoverGrid = () => { ); const handleServerClick = (server: Record) => { - toggleAddServerModal(true, { + openModal("addServer", { name: server.name, host: server.server || "", // Use empty string if server is undefined port: server.port || "443", // Default to 443 if port is undefined diff --git a/src/components/ui/ImagePreviewModal.tsx b/src/components/ui/ImagePreviewModal.tsx index 64a95fa6..56dac505 100644 --- a/src/components/ui/ImagePreviewModal.tsx +++ b/src/components/ui/ImagePreviewModal.tsx @@ -1,3 +1,5 @@ +import { SimpleModal } from "../modals"; + /** * Modal for previewing and uploading images */ @@ -22,39 +24,41 @@ export function ImagePreviewModal({ }: ImagePreviewModalProps) { if (!isOpen || !previewUrl) return null; + const footerContent = ( + <> + + + + ); + return ( -
-
-
-

- Upload Image -

-
- Preview -
-

- File: {file?.name} ({(file?.size || 0) / 1024} KB) -

-
-
- - -
+ +
+ Preview
-
+

+ File: {file?.name} ({((file?.size || 0) / 1024).toFixed(2)} KB) +

+ ); } diff --git a/src/components/ui/InviteUserModal.tsx b/src/components/ui/InviteUserModal.tsx index 71d28701..7e228c3b 100644 --- a/src/components/ui/InviteUserModal.tsx +++ b/src/components/ui/InviteUserModal.tsx @@ -1,7 +1,8 @@ import type React from "react"; import { useState } from "react"; -import { FaTimes, FaUserPlus } from "react-icons/fa"; +import { FaUserPlus } from "react-icons/fa"; import ircClient from "../../lib/ircClient"; +import { SimpleModal } from "../modals"; interface InviteUserModalProps { isOpen: boolean; @@ -42,8 +43,6 @@ const InviteUserModal: React.FC = ({ if (e.key === "Enter") { e.preventDefault(); handleInvite(); - } else if (e.key === "Escape") { - onClose(); } }; @@ -53,72 +52,65 @@ const InviteUserModal: React.FC = ({ onClose(); }; - return ( -
-
- {/* Header */} -
-
- -

- Invite User to {channelName} -

-
- -
+ const modalTitle = ( +
+ + Invite User to {channelName} +
+ ); - {/* Content */} -
-
- - { - setUsername(e.target.value); - setError(""); - }} - onKeyDown={handleKeyDown} - placeholder="Enter username to invite" - className="w-full px-3 py-2 bg-discord-dark-500 text-discord-text-normal rounded border border-discord-dark-400 focus:border-discord-blurple focus:outline-none" - autoFocus - /> - {error &&

{error}

} -
+ const footerContent = ( + <> + + + + ); -
- The user will receive an invitation to join {channelName}. -
-
+ return ( + +
+ + { + setUsername(e.target.value); + setError(""); + }} + onKeyDown={handleKeyDown} + placeholder="Enter username to invite" + className="w-full px-3 py-2 bg-discord-dark-500 text-discord-text-normal rounded border border-discord-dark-400 focus:border-discord-blurple focus:outline-none" + autoFocus + /> + {error &&

{error}

} +
- {/* Footer */} -
- - -
+
+ The user will receive an invitation to join {channelName}.
-
+ ); }; diff --git a/src/components/ui/ModerationModal.tsx b/src/components/ui/ModerationModal.tsx index aee171b5..3acfc9ac 100644 --- a/src/components/ui/ModerationModal.tsx +++ b/src/components/ui/ModerationModal.tsx @@ -1,6 +1,6 @@ import type React from "react"; import { useState } from "react"; -import { FaTimes } from "react-icons/fa"; +import { SimpleModal } from "../modals"; export type ModerationAction = "warn" | "kick" | "ban-nick" | "ban-hostmask"; @@ -66,81 +66,76 @@ const ModerationModal: React.FC = ({ if (!isOpen) return null; - return ( -
-
-
-

- {getActionTitle(action)} -

- -
- -
-
-
- - -
+ const footerContent = ( +
+ + +
+ ); -
- - -
+ return ( + + +
+
+ + +
-
- - setReason(e.target.value)} - className="w-full p-2 bg-discord-dark-300 text-white rounded" - placeholder="Enter reason (optional)" - autoFocus - /> -

- Will default to "no reason" if left empty -

-
+
+ + +
-
- - -
+
+ + setReason(e.target.value)} + className="w-full p-2 bg-discord-dark-300 text-white rounded" + placeholder="Enter reason (optional)" + autoFocus + /> +

+ Will default to "no reason" if left empty +

- -
-
+
+ + ); }; diff --git a/src/components/ui/UserProfileModal.tsx b/src/components/ui/UserProfileModal.tsx index 5bb4776a..03841af4 100644 --- a/src/components/ui/UserProfileModal.tsx +++ b/src/components/ui/UserProfileModal.tsx @@ -87,9 +87,7 @@ const UserProfileModal: React.FC = ({ const selectChannel = useStore((state) => state.selectChannel); const openPrivateChat = useStore((state) => state.openPrivateChat); const selectPrivateChat = useStore((state) => state.selectPrivateChat); - const toggleUserProfileModal = useStore( - (state) => state.toggleUserProfileModal, - ); + const openModal = useStore((state) => state.openModal); const server = servers.find((s) => s.id === serverId); // Get user metadata from channels @@ -615,7 +613,7 @@ const UserProfileModal: React.FC = ({ - ); - })} - -
-
+ // Track unsaved changes with automatic deep comparison + const { hasChanges: hasUnsavedChanges, originalValues } = useUnsavedChanges( + { + avatar, + displayName, + realname, + homepage, + status, + color, + bot, + newNickname, + enableNotificationSounds, + notificationSound, + enableHighlights, + sendTypingNotifications, + nickname, + accountName, + accountPassword, + operName, + operPassword, + operOnConnect, + showSafeMedia, + showExternalContent, + enableMarkdownRendering, + showEvents: globalShowEvents, + showNickChanges: globalShowNickChanges, + showJoinsParts: globalShowJoinsParts, + showQuits: globalShowQuits, + showKicks: globalShowKicks, + enableMultilineInput: globalEnableMultilineInput, + multilineOnShiftEnter: globalMultilineOnShiftEnter, + autoFallbackToSingleLine: globalAutoFallbackToSingleLine, + }, + isOpen, + ); - {/* Main content */} -
-
-

- {categories.find((c) => c.id === activeCategory)?.name} -

- -
+ // Sidebar content + const sidebarContent = ( + <> +
+ {isMobile ? ( + + ) : ( +

User Settings

+ )} +
+
+ +
+ + ); -
- {renderActiveCategory()} -
+ // Footer content + const footerContent = ( + <> + + + + ); -
- - -
-
-
+ return ( + <> + c.id === activeCategory)?.name || + "User Settings" + } + sidebar={sidebarContent} + footer={footerContent} + maxWidth="4xl" + sidebarWidth={isMobile ? "60px" : "250px"} + > + {renderActiveCategory()} + {/* User Profile Modal */} {viewProfileModalOpen && currentUser && currentServer && ( { )} {/* Unsaved Changes Warning Modal */} - {showUnsavedChangesModal && ( -
-
-
-

- Unsaved Changes -

-

- You have unsaved changes. Would you like to save them before - viewing your profile? -

-
- - - -
-
-
-
- )} + setShowUnsavedChangesModal(false)} + onDontSave={() => { + if (currentUser && currentServer) { + setShowUnsavedChangesModal(false); + setProfileViewRequest(currentServer.id, currentUser.username); + closeModal("settings"); + } + }} + onSave={() => { + if (currentUser && currentServer) { + handleSaveAll(); + setShowUnsavedChangesModal(false); + setProfileViewRequest(currentServer.id, currentUser.username); + closeModal("settings"); + } + }} + message="You have unsaved changes. Would you like to save them before viewing your profile?" + /> {/* External Content Warning Modal */} - {showExternalContentWarning && ( -
-
-
-

- ⚠️ External Content Warning -

-

- Enabling external content display will load images and media - from external servers. This may reveal your IP address to those - servers. -

-

- Only enable this if you understand the privacy implications and - trust the content sources. -

-
- - -
-
+ setShowExternalContentWarning(false)} + preventClose={true} + className="bg-discord-dark-300 rounded-lg shadow-xl max-w-md w-full mx-4" + > +
+

+ ⚠️ External Content Warning +

+

+ Enabling external content display will load images and media from + external servers. This may reveal your IP address to those servers. +

+

+ Only enable this if you understand the privacy implications and + trust the content sources. +

+
+ +
- )} -
+ + ); }); diff --git a/src/hooks/useReactions.ts b/src/hooks/useReactions.ts index 293e3eef..52bdff35 100644 --- a/src/hooks/useReactions.ts +++ b/src/hooks/useReactions.ts @@ -120,7 +120,10 @@ export function useReactions({ if (messages) { const msgIndex = messages.findIndex((m) => m.id === message.id); if (msgIndex !== -1) { - const updatedMessage = { ...messages[msgIndex] }; + // Type assertion to avoid TypeScript's type instantiation depth limit with Immer + const updatedMessage = { + ...(messages[msgIndex] as unknown as Message), + }; if (existingReaction) { // Remove the reaction updatedMessage.reactions = updatedMessage.reactions.filter( @@ -136,14 +139,17 @@ export function useReactions({ { emoji, userId: currentUser?.username || "" }, ]; } + // Type assertion to avoid TypeScript's type instantiation depth limit with Immer + const messagesArray = messages as unknown as Message[]; + const newMessages = [ + ...messagesArray.slice(0, msgIndex), + updatedMessage, + ...messagesArray.slice(msgIndex + 1), + ]; return { messages: { ...state.messages, - [key]: [ - ...messages.slice(0, msgIndex), - updatedMessage, - ...messages.slice(msgIndex + 1), - ], + [key]: newMessages, }, }; } @@ -184,7 +190,10 @@ export function useReactions({ if (messages) { const msgIndex = messages.findIndex((m) => m.id === message.id); if (msgIndex !== -1) { - const updatedMessage = { ...messages[msgIndex] }; + // Type assertion to avoid TypeScript's type instantiation depth limit with Immer + const updatedMessage = { + ...(messages[msgIndex] as unknown as Message), + }; // Check if user already reacted const existingReactionIndex = updatedMessage.reactions.findIndex( (r) => r.emoji === emoji && r.userId === currentUser?.username, @@ -194,14 +203,17 @@ export function useReactions({ ...updatedMessage.reactions, { emoji, userId: currentUser?.username || "" }, ]; + // Type assertion to avoid TypeScript's type instantiation depth limit with Immer + const messagesArray = messages as unknown as Message[]; + const newMessages = [ + ...messagesArray.slice(0, msgIndex), + updatedMessage, + ...messagesArray.slice(msgIndex + 1), + ]; return { messages: { ...state.messages, - [key]: [ - ...messages.slice(0, msgIndex), - updatedMessage, - ...messages.slice(msgIndex + 1), - ], + [key]: newMessages, }, }; } @@ -234,20 +246,26 @@ export function useReactions({ if (messages) { const msgIndex = messages.findIndex((m) => m.id === message.id); if (msgIndex !== -1) { - const updatedMessage = { ...messages[msgIndex] }; + // Type assertion to avoid TypeScript's type instantiation depth limit with Immer + const updatedMessage = { + ...(messages[msgIndex] as unknown as Message), + }; // Remove the reaction updatedMessage.reactions = updatedMessage.reactions.filter( (r) => !(r.emoji === emoji && r.userId === currentUser?.username), ); + // Type assertion to avoid TypeScript's type instantiation depth limit with Immer + const messagesArray = messages as unknown as Message[]; + const newMessages = [ + ...messagesArray.slice(0, msgIndex), + updatedMessage, + ...messagesArray.slice(msgIndex + 1), + ]; return { messages: { ...state.messages, - [key]: [ - ...messages.slice(0, msgIndex), - updatedMessage, - ...messages.slice(msgIndex + 1), - ], + [key]: newMessages, }, }; } diff --git a/src/lib/irc/capabilities/capabilityNegotiator.ts b/src/lib/irc/capabilities/capabilityNegotiator.ts new file mode 100644 index 00000000..6e34bfea --- /dev/null +++ b/src/lib/irc/capabilities/capabilityNegotiator.ts @@ -0,0 +1,188 @@ +import type { StateManager } from "../core/state"; +import type { EventEmitter } from "../events/eventEmitter"; + +export class CapabilityNegotiator { + private eventEmitter: EventEmitter; + private stateManager: StateManager; + private sendRaw: (serverId: string, data: string) => void; + private userOnConnect: (serverId: string) => void; + + private readonly supportedCapabilities: string[] = [ + "multi-prefix", + "message-tags", + "server-time", + "echo-message", + "userhost-in-names", + "draft/chathistory", + "draft/extended-isupport", + "sasl", + "cap-notify", + "draft/channel-rename", + "setname", + "account-notify", + "account-tag", + "extended-join", + "away-notify", + "batch", + "invite-notify", + "chghost", + "labeled-response", + "multiline", + "redact", + ]; + + private capLsAccumulated: Map> = new Map(); + private pendingCapReqs: Map = new Map(); + private saslMechanisms: Map = new Map(); + + constructor( + eventEmitter: EventEmitter, + stateManager: StateManager, + sendRaw: (serverId: string, data: string) => void, + userOnConnect: (serverId: string) => void, + ) { + this.eventEmitter = eventEmitter; + this.stateManager = stateManager; + this.sendRaw = sendRaw; + this.userOnConnect = userOnConnect; + } + + handleCapLs(serverId: string, cliCaps: string, isFinal: boolean): void { + let accumulated = this.capLsAccumulated.get(serverId); + if (!accumulated) { + accumulated = new Set(); + this.capLsAccumulated.set(serverId, accumulated); + } + + const caps = cliCaps.split(" "); + for (const c of caps) { + const [cap, value] = c.split("=", 2); + accumulated.add(cap); + + if (cap === "sasl" && value) { + this.saslMechanisms.set(serverId, value.split(",")); + } + + if (cap === "unrealircd.org/link-security" && value) { + const linkSecurityValue = Number.parseInt(value, 10) || 0; + this.eventEmitter.triggerEvent("CAP LS", { + serverId, + cliCaps: `unrealircd.org/link-security=${linkSecurityValue}`, + }); + } + } + + if (isFinal) { + const capsToRequest: string[] = []; + const saslEnabled = this.stateManager.isSaslEnabled(serverId); + + for (const cap of accumulated) { + if ( + (this.supportedCapabilities.includes(cap) || + cap.startsWith("draft/metadata")) && + (cap !== "sasl" || saslEnabled) + ) { + capsToRequest.push(cap); + } + } + + if (capsToRequest.length > 0) { + let currentBatch: string[] = []; + const baseLength = "CAP REQ :".length + 2; + let currentLength = baseLength; + let batchCount = 0; + + for (const cap of capsToRequest) { + const capLength = cap.length + (currentBatch.length > 0 ? 1 : 0); + + if (currentLength + capLength > 500 && currentBatch.length > 0) { + this.sendRaw(serverId, `CAP REQ :${currentBatch.join(" ")}`); + batchCount++; + currentBatch = []; + currentLength = baseLength; + } + + currentBatch.push(cap); + currentLength += capLength; + } + + if (currentBatch.length > 0) { + this.sendRaw(serverId, `CAP REQ :${currentBatch.join(" ")}`); + batchCount++; + } + + this.pendingCapReqs.set(serverId, batchCount); + + setTimeout(() => { + if (this.pendingCapReqs.has(serverId)) { + this.pendingCapReqs.delete(serverId); + this.sendRaw(serverId, "CAP END"); + this.stateManager.setCapNegotiationComplete(serverId, true); + this.userOnConnect(serverId); + } + }, 5000); + + if (capsToRequest.includes("draft/extended-isupport")) { + this.sendRaw(serverId, "ISUPPORT"); + } + } + + this.capLsAccumulated.delete(serverId); + } + } + + handleCapAck(serverId: string, cliCaps: string): void { + this.eventEmitter.triggerEvent("CAP ACK", { serverId, cliCaps }); + + const pendingCount = this.pendingCapReqs.get(serverId) || 0; + if (pendingCount > 0) { + const newCount = pendingCount - 1; + + if (newCount === 0) { + this.pendingCapReqs.delete(serverId); + } else { + this.pendingCapReqs.set(serverId, newCount); + } + } + } + + handleCapNew(serverId: string, cliCaps: string): void { + const caps = cliCaps.split(" "); + for (const c of caps) { + const [cap, value] = c.split("=", 2); + if (cap === "sasl" && value) { + this.saslMechanisms.set(serverId, value.split(",")); + } + } + } + + handleCapDel(serverId: string, cliCaps: string): void { + const caps = cliCaps.split(" "); + for (const c of caps) { + const [cap] = c.split("=", 2); + if (cap === "sasl") { + this.saslMechanisms.delete(serverId); + } + } + } + + handleCapNak(serverId: string): void { + this.sendRaw(serverId, "CAP END"); + this.stateManager.setCapNegotiationComplete(serverId, true); + } + + getSaslMechanisms(serverId: string): string[] | undefined { + return this.saslMechanisms.get(serverId); + } + + isSupported(capability: string): boolean { + return ( + this.supportedCapabilities.includes(capability) || + capability.startsWith("draft/metadata") + ); + } + + getSupportedCapabilities(): string[] { + return [...this.supportedCapabilities]; + } +} diff --git a/src/lib/irc/capabilities/metadata.ts b/src/lib/irc/capabilities/metadata.ts new file mode 100644 index 00000000..68cf7e6c --- /dev/null +++ b/src/lib/irc/capabilities/metadata.ts @@ -0,0 +1,181 @@ +import type { EventEmitter } from "../events/eventEmitter"; + +export interface MetadataValue { + key: string; + value: string; + visibility: string; +} + +export class MetadataManager { + // biome-ignore lint/correctness/noUnusedPrivateClassMembers: Used in constructor + private eventEmitter: EventEmitter; + private sendRaw: (serverId: string, data: string) => void; + private getNick: (serverId: string) => string | undefined; + private subscriptions: Map> = new Map(); + private metadataCache: Map>> = + new Map(); + + constructor( + eventEmitter: EventEmitter, + sendRaw: (serverId: string, data: string) => void, + getNick: (serverId: string) => string | undefined, + ) { + this.eventEmitter = eventEmitter; + this.sendRaw = sendRaw; + this.getNick = getNick; + } + + get(serverId: string, target: string, keys: string[]): void { + const keysStr = keys.join(" "); + this.sendRaw(serverId, `METADATA ${target} GET ${keysStr}`); + } + + list(serverId: string, target: string): void { + this.sendRaw(serverId, `METADATA ${target} LIST`); + } + + set( + serverId: string, + target: string, + key: string, + value?: string, + _visibility?: string, + ): void { + const currentNick = this.getNick(serverId); + const actualTarget = + target === "*" || target === currentNick ? "*" : target; + + const command = + value !== undefined && value !== "" + ? `METADATA ${actualTarget} SET ${key} :${value}` + : `METADATA ${actualTarget} SET ${key}`; + + this.sendRaw(serverId, command); + } + + clear(serverId: string, target: string): void { + this.sendRaw(serverId, `METADATA ${target} CLEAR`); + + const serverCache = this.metadataCache.get(serverId); + if (serverCache) { + serverCache.delete(target); + } + } + + subscribe(serverId: string, keys: string[]): void { + for (const key of keys) { + this.sendRaw(serverId, `METADATA * SUB ${key}`); + } + + let subs = this.subscriptions.get(serverId); + if (!subs) { + subs = new Set(); + this.subscriptions.set(serverId, subs); + } + for (const key of keys) { + subs.add(key); + } + } + + unsubscribe(serverId: string, keys: string[]): void { + const keysStr = keys.join(" "); + this.sendRaw(serverId, `METADATA * UNSUB ${keysStr}`); + + const subs = this.subscriptions.get(serverId); + if (subs) { + for (const key of keys) { + subs.delete(key); + } + } + } + + listSubscriptions(serverId: string): void { + this.sendRaw(serverId, "METADATA * SUBS"); + } + + sync(serverId: string, target: string): void { + this.sendRaw(serverId, `METADATA ${target} SYNC`); + } + + handleMetadataValue( + serverId: string, + target: string, + key: string, + value: string, + visibility: string, + ): void { + let serverCache = this.metadataCache.get(serverId); + if (!serverCache) { + serverCache = new Map(); + this.metadataCache.set(serverId, serverCache); + } + + let targetCache = serverCache.get(target); + if (!targetCache) { + targetCache = new Map(); + serverCache.set(target, targetCache); + } + + targetCache.set(key, { key, value, visibility }); + } + + handleKeyNotSet(serverId: string, target: string, key: string): void { + const serverCache = this.metadataCache.get(serverId); + const targetCache = serverCache?.get(target); + if (targetCache) { + targetCache.delete(key); + } + } + + handleSubOk(serverId: string, keys: string[]): void { + let subs = this.subscriptions.get(serverId); + if (!subs) { + subs = new Set(); + this.subscriptions.set(serverId, subs); + } + for (const key of keys) { + subs.add(key); + } + } + + handleUnsubOk(serverId: string, keys: string[]): void { + const subs = this.subscriptions.get(serverId); + if (subs) { + for (const key of keys) { + subs.delete(key); + } + } + } + + getCachedValue( + serverId: string, + target: string, + key: string, + ): MetadataValue | undefined { + return this.metadataCache.get(serverId)?.get(target)?.get(key); + } + + getCachedMetadata( + serverId: string, + target: string, + ): Map | undefined { + return this.metadataCache.get(serverId)?.get(target); + } + + getSubscriptions(serverId: string): Set { + return this.subscriptions.get(serverId) || new Set(); + } + + isSubscribed(serverId: string, key: string): boolean { + return this.subscriptions.get(serverId)?.has(key) ?? false; + } + + clearCache(serverId: string): void { + this.metadataCache.delete(serverId); + this.subscriptions.delete(serverId); + } + + clearTargetCache(serverId: string, target: string): void { + this.metadataCache.get(serverId)?.delete(target); + } +} diff --git a/src/lib/irc/capabilities/sasl.ts b/src/lib/irc/capabilities/sasl.ts new file mode 100644 index 00000000..f4e5eef1 --- /dev/null +++ b/src/lib/irc/capabilities/sasl.ts @@ -0,0 +1,152 @@ +import type { EventEmitter } from "../events/eventEmitter"; + +export interface SaslCredentials { + username: string; + password: string; +} + +export type SaslMechanism = "PLAIN" | "EXTERNAL" | "SCRAM-SHA-256"; + +export class SaslAuthenticator { + // biome-ignore lint/correctness/noUnusedPrivateClassMembers: Used in constructor + private eventEmitter: EventEmitter; + private sendRaw: (serverId: string, data: string) => void; + private credentials: Map = new Map(); + private availableMechanisms: Map = new Map(); + private saslEnabled: Map = new Map(); + private saslInProgress: Map = new Map(); + + constructor( + eventEmitter: EventEmitter, + sendRaw: (serverId: string, data: string) => void, + ) { + this.eventEmitter = eventEmitter; + this.sendRaw = sendRaw; + } + + setCredentials(serverId: string, username: string, password: string): void { + this.credentials.set(serverId, { username, password }); + this.saslEnabled.set(serverId, true); + } + + getCredentials(serverId: string): SaslCredentials | undefined { + return this.credentials.get(serverId); + } + + isEnabled(serverId: string): boolean { + return this.saslEnabled.get(serverId) ?? false; + } + + setAvailableMechanisms(serverId: string, mechanisms: string[]): void { + this.availableMechanisms.set(serverId, mechanisms); + } + + getAvailableMechanisms(serverId: string): string[] | undefined { + return this.availableMechanisms.get(serverId); + } + + startAuthentication( + serverId: string, + mechanism: SaslMechanism = "PLAIN", + ): boolean { + const creds = this.credentials.get(serverId); + if (!creds) { + console.warn(`No SASL credentials for server ${serverId}`); + return false; + } + + const available = this.availableMechanisms.get(serverId); + if (available && !available.includes(mechanism)) { + console.warn( + `SASL mechanism ${mechanism} not available on server ${serverId}`, + ); + return false; + } + + this.saslInProgress.set(serverId, true); + this.sendRaw(serverId, `AUTHENTICATE ${mechanism}`); + return true; + } + + handleAuthenticateResponse(serverId: string, param: string): void { + if (!this.saslInProgress.get(serverId)) { + return; + } + + const creds = this.credentials.get(serverId); + if (!creds) { + console.warn(`No SASL credentials for server ${serverId}`); + this.abortAuthentication(serverId); + return; + } + + if (param === "+" || param === ":+") { + this.sendPlainAuth(serverId, creds); + } + } + + private sendPlainAuth(serverId: string, creds: SaslCredentials): void { + const authString = `${creds.username}\0${creds.username}\0${creds.password}`; + const base64Auth = this.base64Encode(authString); + const chunks = this.splitIntoChunks(base64Auth, 400); + + for (const chunk of chunks) { + this.sendRaw(serverId, `AUTHENTICATE ${chunk}`); + } + + if (base64Auth.length % 400 === 0 && base64Auth.length > 0) { + this.sendRaw(serverId, "AUTHENTICATE +"); + } + } + + handleSuccess(serverId: string): void { + this.saslInProgress.set(serverId, false); + console.log(`SASL authentication successful for server ${serverId}`); + } + + handleFailure(serverId: string, code: string, message: string): void { + this.saslInProgress.set(serverId, false); + console.warn( + `SASL authentication failed for server ${serverId}: ${code} ${message}`, + ); + } + + abortAuthentication(serverId: string): void { + this.saslInProgress.set(serverId, false); + this.sendRaw(serverId, "AUTHENTICATE *"); + } + + clearCredentials(serverId: string): void { + this.credentials.delete(serverId); + this.saslEnabled.set(serverId, false); + this.saslInProgress.set(serverId, false); + } + + clearMechanisms(serverId: string): void { + this.availableMechanisms.delete(serverId); + } + + isInProgress(serverId: string): boolean { + return this.saslInProgress.get(serverId) ?? false; + } + + private base64Encode(str: string): string { + if (typeof btoa !== "undefined") { + return btoa(str); + } + + if (typeof Buffer !== "undefined") { + return Buffer.from(str, "utf-8").toString("base64"); + } + + throw new Error("No base64 encoding method available"); + } + + private splitIntoChunks(str: string, chunkSize: number): string[] { + const chunks: string[] = []; + for (let i = 0; i < str.length; i += chunkSize) { + chunks.push(str.substring(i, i + chunkSize)); + } + return chunks.length > 0 ? chunks : [""]; + } +} diff --git a/src/lib/irc/core/connection.ts b/src/lib/irc/core/connection.ts new file mode 100644 index 00000000..6af4f73f --- /dev/null +++ b/src/lib/irc/core/connection.ts @@ -0,0 +1,145 @@ +import type { EventEmitter } from "../events/eventEmitter"; + +/** + * Manages WebSocket connections for IRC servers + */ +export class ConnectionManager { + private sockets: Map = new Map(); + private pendingConnections: Map> = new Map(); + + constructor(private eventEmitter: EventEmitter) {} + + /** + * Create a WebSocket connection to a server + */ + async connect( + host: string, + port: number, + serverId: string, + onOpen: (socket: WebSocket) => void, + onMessage: (data: string, serverId: string) => void, + onClose: () => void, + onError: () => void, + ): Promise { + const connectionKey = `${host}:${port}`; + + // Check if there's already a pending connection + const existingConnection = this.pendingConnections.get(connectionKey); + if (existingConnection) { + throw new Error(`Connection to ${host}:${port} is already in progress`); + } + + // Create connection promise + const connectionPromise = new Promise((resolve, reject) => { + // Determine protocol based on host + const protocol = ["localhost", "127.0.0.1"].includes(host) ? "ws" : "wss"; + const url = `${protocol}://${host}:${port}`; + + let socket: WebSocket; + try { + socket = new WebSocket(url); + } catch (error) { + reject(new Error(`Failed to connect to ${host}:${port}`)); + return; + } + + socket.onopen = () => { + this.sockets.set(serverId, socket); + this.eventEmitter.triggerEvent("connectionStateChange", { + serverId, + connectionState: "connected", + }); + onOpen(socket); + resolve(socket); + }; + + socket.onclose = () => { + console.log(`WebSocket closed for server ${serverId}`); + this.sockets.delete(serverId); + this.eventEmitter.triggerEvent("connectionStateChange", { + serverId, + connectionState: "disconnected", + }); + onClose(); + }; + + socket.onerror = (error) => { + console.error(`WebSocket error for server ${serverId}:`, error); + this.sockets.delete(serverId); + this.eventEmitter.triggerEvent("connectionStateChange", { + serverId, + connectionState: "disconnected", + }); + onError(); + reject(new Error(`Failed to connect to ${host}:${port}`)); + }; + + socket.onmessage = (event) => { + onMessage(event.data, serverId); + }; + }); + + // Store and clean up pending connection + this.pendingConnections.set(connectionKey, connectionPromise); + connectionPromise.finally(() => { + this.pendingConnections.delete(connectionKey); + }); + + return connectionPromise; + } + + /** + * Disconnect from a server + */ + disconnect(serverId: string, quitMessage?: string): void { + const socket = this.sockets.get(serverId); + if (socket) { + if (quitMessage && socket.readyState === WebSocket.OPEN) { + socket.send(`QUIT :${quitMessage}`); + } + socket.close(); + this.sockets.delete(serverId); + } + } + + /** + * Get socket for a server + */ + getSocket(serverId: string): WebSocket | undefined { + return this.sockets.get(serverId); + } + + /** + * Check if connected to a server + */ + isConnected(serverId: string): boolean { + const socket = this.sockets.get(serverId); + return socket?.readyState === WebSocket.OPEN; + } + + /** + * Send raw IRC command to server + */ + sendRaw(serverId: string, command: string): void { + const socket = this.sockets.get(serverId); + if (socket && socket.readyState === WebSocket.OPEN) { + console.log( + `IRC Client: Sending command to server ${serverId}: ${command}`, + ); + socket.send(command); + } else { + console.error(`Socket for server ${serverId} is not open`); + } + } + + /** + * Clean up all connections + */ + disconnectAll(): void { + for (const serverId of this.sockets.keys()) { + this.disconnect(serverId); + } + this.sockets.clear(); + this.pendingConnections.clear(); + } +} diff --git a/src/lib/irc/core/ping.ts b/src/lib/irc/core/ping.ts new file mode 100644 index 00000000..5d00c7da --- /dev/null +++ b/src/lib/irc/core/ping.ts @@ -0,0 +1,86 @@ +/** + * Manages WebSocket keepalive through IRC PING/PONG + */ +export class PingManager { + private pingTimers: Map = new Map(); + private pongTimeouts: Map = new Map(); + + /** + * Start sending periodic pings to a server + */ + startPing(serverId: string, sendRaw: (msg: string) => void): void { + // Clear any existing ping timer + this.stopPing(serverId); + + // Send ping every 30 seconds + const pingTimer = setInterval(() => { + try { + const timestamp = Date.now().toString(); + sendRaw(`PING ${timestamp}`); + + // Set a timeout for pong response (10 seconds) + const pongTimeout = setTimeout(() => { + console.warn( + `WebSocket ping timeout for server ${serverId}, closing connection`, + ); + // Connection will be closed by the caller + }, 10000); + + this.pongTimeouts.set(serverId, pongTimeout); + } catch (error) { + console.error(`Failed to send ping for server ${serverId}:`, error); + } + }, 30000); // 30 seconds + + this.pingTimers.set(serverId, pingTimer); + } + + /** + * Stop sending pings to a server + */ + stopPing(serverId: string): void { + const pingTimer = this.pingTimers.get(serverId); + if (pingTimer) { + clearInterval(pingTimer); + this.pingTimers.delete(serverId); + } + + const pongTimeout = this.pongTimeouts.get(serverId); + if (pongTimeout) { + clearTimeout(pongTimeout); + this.pongTimeouts.delete(serverId); + } + } + + /** + * Handle pong response from server + */ + handlePong(serverId: string): void { + const pongTimeout = this.pongTimeouts.get(serverId); + if (pongTimeout) { + clearTimeout(pongTimeout); + this.pongTimeouts.delete(serverId); + } + } + + /** + * Check if ping is active for a server + */ + isPinging(serverId: string): boolean { + return this.pingTimers.has(serverId); + } + + /** + * Stop all ping timers + */ + stopAll(): void { + for (const timer of this.pingTimers.values()) { + clearInterval(timer); + } + for (const timeout of this.pongTimeouts.values()) { + clearTimeout(timeout); + } + this.pingTimers.clear(); + this.pongTimeouts.clear(); + } +} diff --git a/src/lib/irc/core/reconnection.ts b/src/lib/irc/core/reconnection.ts new file mode 100644 index 00000000..5a39b102 --- /dev/null +++ b/src/lib/irc/core/reconnection.ts @@ -0,0 +1,131 @@ +import type { EventEmitter } from "../events/eventEmitter"; + +export type ReconnectCallback = () => Promise; + +/** + * Manages automatic reconnection logic with exponential backoff + */ +export class ReconnectionManager { + private reconnectionAttempts: Map = new Map(); + private reconnectionTimeouts: Map = new Map(); + private reconnectCallbacks: Map = new Map(); + + constructor(private eventEmitter: EventEmitter) {} + + /** + * Start reconnection process for a server + */ + startReconnection(serverId: string, onReconnect: ReconnectCallback): void { + console.log(`Starting reconnection for server ${serverId}`); + + const existingTimeout = this.reconnectionTimeouts.get(serverId); + if (existingTimeout) { + clearTimeout(existingTimeout); + } + + this.reconnectCallbacks.set(serverId, onReconnect); + + const attempts = this.reconnectionAttempts.get(serverId) || 0; + this.reconnectionAttempts.set(serverId, attempts + 1); + + let delay = 0; + if (attempts === 0) { + delay = 0; // Immediate retry + } else if (attempts === 1) { + delay = 15000; // 15 seconds + } else if (attempts === 2) { + delay = 30000; // 30 seconds + } else if (attempts <= 100) { + delay = 60000; // 60 seconds for attempts 3-100 + } else { + this.eventEmitter.triggerEvent("connectionStateChange", { + serverId, + connectionState: "disconnected", + }); + this.clearReconnection(serverId); + return; + } + + this.eventEmitter.triggerEvent("connectionStateChange", { + serverId, + connectionState: "reconnecting", + }); + + const timeout = setTimeout(() => { + console.log( + `Reconnection timeout fired for server ${serverId}, attempting reconnection`, + ); + this.attemptReconnection(serverId); + }, delay); + + this.reconnectionTimeouts.set(serverId, timeout); + } + + /** + * Attempt to reconnect to a server + */ + private async attemptReconnection(serverId: string): Promise { + console.log(`Attempting reconnection for server ${serverId}`); + + const callback = this.reconnectCallbacks.get(serverId); + if (!callback) { + console.error(`No reconnect callback found for server ${serverId}`); + return; + } + + try { + this.eventEmitter.triggerEvent("connectionStateChange", { + serverId, + connectionState: "connecting", + }); + + await callback(); + + console.log(`Reconnection successful for server ${serverId}`); + this.clearReconnection(serverId); + } catch (error) { + console.log(`Reconnection failed for server ${serverId}:`, error); + this.startReconnection(serverId, callback); + } + } + + /** + * Clear reconnection state for a server + */ + clearReconnection(serverId: string): void { + this.reconnectionAttempts.delete(serverId); + this.reconnectCallbacks.delete(serverId); + + const timeout = this.reconnectionTimeouts.get(serverId); + if (timeout) { + clearTimeout(timeout); + this.reconnectionTimeouts.delete(serverId); + } + } + + /** + * Get current reconnection attempt count + */ + getAttemptCount(serverId: string): number { + return this.reconnectionAttempts.get(serverId) || 0; + } + + /** + * Check if reconnection is in progress + */ + isReconnecting(serverId: string): boolean { + return this.reconnectionTimeouts.has(serverId); + } + + /** + * Clean up all reconnection state + */ + clearAll(): void { + for (const timeout of this.reconnectionTimeouts.values()) { + clearTimeout(timeout); + } + this.reconnectionTimeouts.clear(); + this.reconnectionAttempts.clear(); + this.reconnectCallbacks.clear(); + } +} diff --git a/src/lib/irc/core/state.ts b/src/lib/irc/core/state.ts new file mode 100644 index 00000000..bacdb24a --- /dev/null +++ b/src/lib/irc/core/state.ts @@ -0,0 +1,227 @@ +import type { Server, User } from "../../../types"; + +export interface BatchInfo { + type: string; + parameters?: string[]; + messages: string[]; + timestamps?: Date[]; + concatFlags?: boolean[]; + sender?: string; + messageIds?: string[]; + batchMsgId?: string; + batchTime?: Date; +} + +export class StateManager { + private servers: Map = new Map(); + private nicks: Map = new Map(); + private currentUsers: Map = new Map(); + private activeBatches: Map> = new Map(); + private saslMechanisms: Map = new Map(); + private capLsAccumulated: Map> = new Map(); + private saslEnabled: Map = new Map(); + private saslCredentials: Map = + new Map(); + private capNegotiationComplete: Map = new Map(); + private pendingCapReqs: Map = new Map(); + + addServer(serverId: string, server: Server): void { + this.servers.set(serverId, server); + } + + getServer(serverId: string): Server | undefined { + return this.servers.get(serverId); + } + + removeServer(serverId: string): void { + this.servers.delete(serverId); + this.nicks.delete(serverId); + this.currentUsers.delete(serverId); + this.activeBatches.delete(serverId); + this.saslMechanisms.delete(serverId); + this.capLsAccumulated.delete(serverId); + this.saslEnabled.delete(serverId); + this.saslCredentials.delete(serverId); + this.capNegotiationComplete.delete(serverId); + this.pendingCapReqs.delete(serverId); + } + + getAllServers(): Server[] { + return Array.from(this.servers.values()); + } + + getServers(): Server[] { + return this.getAllServers(); + } + + getAllUsers(serverId: string): User[] { + const server = this.getServer(serverId); + if (!server) return []; + + const allUsers = new Map(); + + // Collect users from all joined channels + for (const channel of server.channels) { + for (const user of channel.users) { + allUsers.set(user.username, user); + } + } + + return Array.from(allUsers.values()); + } + + setNick(serverId: string, nick: string): void { + this.nicks.set(serverId, nick); + } + + getNick(serverId: string): string | undefined { + return this.nicks.get(serverId); + } + + setCurrentUser(serverId: string, user: User | null): void { + this.currentUsers.set(serverId, user); + } + + getCurrentUser(serverId: string): User | null { + return this.currentUsers.get(serverId) || null; + } + + updateCurrentUser(serverId: string, updates: Partial): void { + const currentUser = this.currentUsers.get(serverId); + if (currentUser) { + this.currentUsers.set(serverId, { ...currentUser, ...updates }); + } + } + + startBatch( + serverId: string, + batchId: string, + type: string, + parameters?: string[], + batchMsgId?: string, + batchTime?: Date, + ): void { + if (!this.activeBatches.has(serverId)) { + this.activeBatches.set(serverId, new Map()); + } + this.activeBatches.get(serverId)?.set(batchId, { + type, + parameters, + messages: [], + timestamps: [], + concatFlags: [], + messageIds: [], + batchMsgId, + batchTime, + }); + } + + getBatch(serverId: string, batchId: string): BatchInfo | undefined { + return this.activeBatches.get(serverId)?.get(batchId); + } + + endBatch(serverId: string, batchId: string): BatchInfo | undefined { + const serverBatches = this.activeBatches.get(serverId); + const batch = serverBatches?.get(batchId); + serverBatches?.delete(batchId); + return batch; + } + + addMessageToBatch( + serverId: string, + batchId: string, + message: string, + sender?: string, + msgid?: string, + timestamp?: Date, + hasConcat?: boolean, + ): void { + const batch = this.getBatch(serverId, batchId); + if (batch) { + batch.messages.push(message); + if (!batch.sender) { + batch.sender = sender; + } + if (msgid && batch.messageIds) { + batch.messageIds.push(msgid); + } + if (timestamp && batch.timestamps) { + batch.timestamps.push(timestamp); + } + if (batch.concatFlags) { + batch.concatFlags.push(!!hasConcat); + } + } + } + + setSaslEnabled(serverId: string, enabled: boolean): void { + this.saslEnabled.set(serverId, enabled); + } + + isSaslEnabled(serverId: string): boolean { + return this.saslEnabled.get(serverId) ?? false; + } + + setSaslCredentials( + serverId: string, + username: string, + password: string, + ): void { + this.saslCredentials.set(serverId, { username, password }); + } + + getSaslCredentials( + serverId: string, + ): { username: string; password: string } | undefined { + return this.saslCredentials.get(serverId); + } + + setSaslMechanisms(serverId: string, mechanisms: string[]): void { + this.saslMechanisms.set(serverId, mechanisms); + } + + getSaslMechanisms(serverId: string): string[] | undefined { + return this.saslMechanisms.get(serverId); + } + + deleteSaslMechanisms(serverId: string): void { + this.saslMechanisms.delete(serverId); + } + + getCapLsAccumulated(serverId: string): Set { + let accumulated = this.capLsAccumulated.get(serverId); + if (!accumulated) { + accumulated = new Set(); + this.capLsAccumulated.set(serverId, accumulated); + } + return accumulated; + } + + deleteCapLsAccumulated(serverId: string): void { + this.capLsAccumulated.delete(serverId); + } + + setCapNegotiationComplete(serverId: string, complete: boolean): void { + this.capNegotiationComplete.set(serverId, complete); + } + + isCapNegotiationComplete(serverId: string): boolean { + return this.capNegotiationComplete.get(serverId) ?? false; + } + + deleteCapNegotiationComplete(serverId: string): void { + this.capNegotiationComplete.delete(serverId); + } + + setPendingCapReqs(serverId: string, count: number): void { + this.pendingCapReqs.set(serverId, count); + } + + getPendingCapReqs(serverId: string): number { + return this.pendingCapReqs.get(serverId) || 0; + } + + deletePendingCapReqs(serverId: string): void { + this.pendingCapReqs.delete(serverId); + } +} diff --git a/src/lib/irc/events/eventEmitter.ts b/src/lib/irc/events/eventEmitter.ts new file mode 100644 index 00000000..14fb374e --- /dev/null +++ b/src/lib/irc/events/eventEmitter.ts @@ -0,0 +1,70 @@ +import type { EventCallback, EventKey, EventMap } from "../types"; + +/** + * Type-safe event emitter for IRC client events + */ +export class EventEmitter { + private eventCallbacks: { + [K in EventKey]?: EventCallback[]; + } = {}; + + /** + * Subscribe to an event + */ + on(event: K, callback: EventCallback): void { + if (!this.eventCallbacks[event]) { + this.eventCallbacks[event] = []; + } + this.eventCallbacks[event]?.push(callback); + } + + /** + * Unsubscribe from an event + */ + deleteHook(event: K, callback: EventCallback): void { + const cbs = this.eventCallbacks[event]; + if (!cbs) return; + const index = cbs.indexOf(callback); + if (index !== -1) { + cbs.splice(index, 1); + } + } + + /** + * Trigger an event with data + */ + triggerEvent(event: K, data: EventMap[K]): void { + const cbs = this.eventCallbacks[event]; + if (!cbs) return; + for (const cb of cbs) { + cb(data); + } + } + + /** + * Check if there are any listeners for an event + */ + hasListeners(event: K): boolean { + const cbs = this.eventCallbacks[event]; + return !!cbs && cbs.length > 0; + } + + /** + * Remove all listeners for a specific event or all events + */ + removeAllListeners(event?: K): void { + if (event) { + delete this.eventCallbacks[event]; + } else { + this.eventCallbacks = {}; + } + } + + /** + * Get the number of listeners for an event + */ + listenerCount(event: K): number { + const cbs = this.eventCallbacks[event]; + return cbs ? cbs.length : 0; + } +} diff --git a/src/lib/irc/handlers/baseHandler.ts b/src/lib/irc/handlers/baseHandler.ts new file mode 100644 index 00000000..a78463e7 --- /dev/null +++ b/src/lib/irc/handlers/baseHandler.ts @@ -0,0 +1,41 @@ +import type { StateManager } from "../core/state"; +import type { EventEmitter } from "../events/eventEmitter"; +import type { CommandHandler } from "../protocol/commandRouter"; +import type { ParsedMessage } from "../protocol/messageParser"; +import type { EventKey, EventMap } from "../types"; + +/** + * Base class for all IRC command handlers + */ +export abstract class BaseHandler implements CommandHandler { + constructor( + protected eventEmitter: EventEmitter, + protected stateManager: StateManager, + ) {} + + /** + * Handle an IRC command - must be implemented by subclasses + */ + abstract handle(message: ParsedMessage, serverId: string): void; + + /** + * Emit an event + */ + protected emit(event: K, data: EventMap[K]): void { + this.eventEmitter.triggerEvent(event, data); + } + + /** + * Get server from state + */ + protected getServer(serverId: string) { + return this.stateManager.getServer(serverId); + } + + /** + * Get nick for a server + */ + protected getNick(serverId: string) { + return this.stateManager.getNick(serverId); + } +} diff --git a/src/lib/irc/handlers/batchHandlers.ts b/src/lib/irc/handlers/batchHandlers.ts new file mode 100644 index 00000000..ed3f4bca --- /dev/null +++ b/src/lib/irc/handlers/batchHandlers.ts @@ -0,0 +1,88 @@ +import type { ParsedMessage } from "../protocol/messageParser"; +import { getTimestampFromTags } from "../utils/ircUtils"; +import { BaseHandler } from "./baseHandler"; + +/** + * Handles BATCH command + */ +export class BatchHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const batchRef = msg.params[0]; + const isStart = batchRef.startsWith("+"); + const batchId = batchRef.substring(1); // Remove + or - + + if (isStart) { + const batchType = msg.params[1]; + const parameters = msg.params.slice(2); + + this.stateManager.startBatch( + serverId, + batchId, + batchType, + parameters, + msg.tags?.msgid, + msg.tags?.time ? new Date(msg.tags.time) : undefined, + ); + + this.emit("BATCH_START", { + serverId, + batchId, + type: batchType, + parameters, + }); + } else { + // Process completed batch + const batch = this.stateManager.endBatch(serverId, batchId); + + if ( + batch && + (batch.type === "multiline" || batch.type === "draft/multiline") + ) { + // Handle completed multiline batch + const target = + batch.parameters && batch.parameters.length > 0 + ? batch.parameters[0] + : ""; + const sender = batch.sender || "unknown"; + + // Combine messages, handling draft/multiline-concat tags + let combinedMessage = ""; + batch.messages.forEach((message, index) => { + const wasConcat = batch.concatFlags?.[index]; + + if (index === 0) { + combinedMessage = message; + } else { + if (wasConcat) { + // Concatenate directly without separator + combinedMessage += message; + } else { + // Join with newline (normal multiline) + combinedMessage += `\n${message}`; + } + } + }); + + this.emit("MULTILINE_MESSAGE", { + serverId, + mtags: batch.batchMsgId ? { msgid: batch.batchMsgId } : undefined, + sender, + channelName: target.startsWith("#") ? target : undefined, + message: combinedMessage, + lines: batch.messages, + messageIds: batch.messageIds || [], + timestamp: + batch.batchTime || + (batch.timestamps && batch.timestamps.length > 0 + ? new Date(Math.min(...batch.timestamps.map((t) => t.getTime()))) + : getTimestampFromTags(msg.tags)), + }); + } + + this.emit("BATCH_END", { + serverId, + batchId, + }); + } + } +} diff --git a/src/lib/irc/handlers/channelHandlers.ts b/src/lib/irc/handlers/channelHandlers.ts new file mode 100644 index 00000000..ac325bc0 --- /dev/null +++ b/src/lib/irc/handlers/channelHandlers.ts @@ -0,0 +1,78 @@ +import type { ParsedMessage } from "../protocol/messageParser"; +import { getNickFromNuh } from "../utils/ircUtils"; +import { BaseHandler } from "./baseHandler"; + +/** + * Handles MODE command + */ +export class ModeHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const sender = getNickFromNuh(msg.source); + const target = msg.params[0]; + const modestring = msg.params[1] || ""; + const modeargs = msg.params.slice(2); + + this.emit("MODE", { + serverId, + mtags: msg.tags, + sender, + target, + modestring, + modeargs, + }); + } +} + +/** + * Handles TOPIC command + */ +export class TopicHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const channelName = msg.params[0]; + const topic = msg.params.slice(1).join(" "); + const sender = getNickFromNuh(msg.source); + + this.emit("TOPIC", { + serverId, + channelName, + topic, + sender, + }); + } +} + +/** + * Handles RENAME command (draft/channel-rename) + */ +export class RenameHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const user = getNickFromNuh(msg.source); + const oldName = msg.params[0]; + const newName = msg.params[1]; + const reason = msg.params.slice(2).join(" "); + + this.emit("RENAME", { + serverId, + oldName, + newName, + reason, + user, + }); + } +} + +/** + * Handles SETNAME command + */ +export class SetnameHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const user = getNickFromNuh(msg.source); + const realname = msg.params.join(" "); + + this.emit("SETNAME", { + serverId, + user, + realname, + }); + } +} diff --git a/src/lib/irc/handlers/messageHandlers.ts b/src/lib/irc/handlers/messageHandlers.ts new file mode 100644 index 00000000..643616ac --- /dev/null +++ b/src/lib/irc/handlers/messageHandlers.ts @@ -0,0 +1,130 @@ +import type { ParsedMessage } from "../protocol/messageParser"; +import { getNickFromNuh, getTimestampFromTags } from "../utils/ircUtils"; +import { BaseHandler } from "./baseHandler"; + +/** + * Handles PRIVMSG command + */ +export class PrivmsgHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const target = msg.params[0]; + const isChannel = target.startsWith("#"); + const sender = getNickFromNuh(msg.source); + const message = msg.params.slice(1).join(" "); + + // Check if this message is part of a multiline batch + const batchId = msg.tags?.batch; + if (batchId) { + const batch = this.stateManager.getBatch(serverId, batchId); + if ( + batch && + (batch.type === "multiline" || batch.type === "draft/multiline") + ) { + // Add this message line to the batch + this.stateManager.addMessageToBatch( + serverId, + batchId, + message, + sender, + msg.tags?.msgid, + getTimestampFromTags(msg.tags), + msg.tags && msg.tags["draft/multiline-concat"] !== undefined, + ); + return; // Don't trigger individual message event + } + } + + if (isChannel) { + this.emit("CHANMSG", { + serverId, + mtags: msg.tags, + sender, + channelName: target, + message, + timestamp: getTimestampFromTags(msg.tags), + }); + } else { + this.emit("USERMSG", { + serverId, + mtags: msg.tags, + sender, + target, + message, + timestamp: getTimestampFromTags(msg.tags), + }); + } + } +} + +/** + * Handles NOTICE command + */ +export class NoticeHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const target = msg.params[0]; + const isChannel = target.startsWith("#"); + const sender = getNickFromNuh(msg.source); + const message = msg.params.slice(1).join(" "); + + if (isChannel) { + this.emit("CHANNNOTICE", { + serverId, + mtags: msg.tags, + sender, + channelName: target, + message, + timestamp: getTimestampFromTags(msg.tags), + }); + } else { + this.emit("USERNOTICE", { + serverId, + mtags: msg.tags, + sender, + message, + timestamp: getTimestampFromTags(msg.tags), + }); + } + } +} + +/** + * Handles TAGMSG command + */ +export class TagmsgHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const rawTarget = msg.params[0] || ""; + const target = rawTarget.startsWith(":") + ? rawTarget.substring(1) + : rawTarget; + const sender = getNickFromNuh(msg.source); + + this.emit("TAGMSG", { + serverId, + mtags: msg.tags, + sender, + channelName: target, + timestamp: getTimestampFromTags(msg.tags), + }); + } +} + +/** + * Handles REDACT command + */ +export class RedactHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const target = msg.params[0]; + const msgid = msg.params[1]; + const reason = msg.params[2] ? msg.params[2].substring(1) : ""; + const sender = getNickFromNuh(msg.source); + + this.emit("REDACT", { + serverId, + mtags: msg.tags, + target, + msgid, + reason, + sender, + }); + } +} diff --git a/src/lib/irc/handlers/numericHandlers.ts b/src/lib/irc/handlers/numericHandlers.ts new file mode 100644 index 00000000..48f01205 --- /dev/null +++ b/src/lib/irc/handlers/numericHandlers.ts @@ -0,0 +1,505 @@ +import type { ParsedMessage } from "../protocol/messageParser"; +import { parseIsupport, parseNamesResponse } from "../utils/ircUtils"; +import { BaseHandler } from "./baseHandler"; + +export class WelcomeHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const serverName = msg.source; + const nickname = msg.params[0]; + + this.stateManager.setNick(serverId, nickname); + this.stateManager.updateCurrentUser(serverId, { username: nickname }); + + this.emit("ready", { serverId, serverName, nickname }); + + const server = this.getServer(serverId); + if (server && server.channels.length > 0) { + console.log( + `Rejoining ${server.channels.length} channels after reconnection`, + ); + } + } +} + +export class YourHostHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const message = msg.params.slice(1).join(" "); + const match = message.match(/Your host is ([^,]+), running version (.+)/); + if (match) { + this.emit("RPL_YOURHOST", { + serverId, + serverName: match[1], + version: match[2], + }); + } + } +} + +export class IsupportHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const capabilities = parseIsupport(msg.params.join(" ")); + for (const [key, value] of Object.entries(capabilities)) { + if (key === "NETWORK") { + const server = this.getServer(serverId); + if (server) { + server.networkName = value; + } + } + this.emit("ISUPPORT", { serverId, key, value }); + } + } +} + +export class RplAwayHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const nick = msg.params[1]; + const awayMessage = msg.params.slice(2).join(" "); + this.emit("RPL_AWAY", { serverId, nick, awayMessage }); + } +} + +export class RplUnawayHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const message = msg.params.slice(1).join(" "); + this.emit("RPL_UNAWAY", { serverId, message }); + } +} + +export class RplNowawayHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const message = msg.params.slice(1).join(" "); + this.emit("RPL_NOWAWAY", { serverId, message }); + } +} + +export class WhoisUserHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const nick = msg.params[1]; + const username = msg.params[2]; + const host = msg.params[3]; + const realname = msg.params.slice(5).join(" "); + this.emit("WHOIS_USER", { serverId, nick, username, host, realname }); + } +} + +export class WhoisServerHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const nick = msg.params[1]; + const server = msg.params[2]; + const serverInfo = msg.params.slice(3).join(" "); + this.emit("WHOIS_SERVER", { serverId, nick, server, serverInfo }); + } +} + +export class EndOfWhoHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const mask = msg.params[1]; + this.emit("WHO_END", { serverId, mask }); + } +} + +export class WhoisIdleHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const nick = msg.params[1]; + const idle = Number.parseInt(msg.params[2], 10); + const signon = Number.parseInt(msg.params[3], 10); + this.emit("WHOIS_IDLE", { serverId, nick, idle, signon }); + } +} + +export class EndOfWhoisHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const nick = msg.params[1]; + this.emit("WHOIS_END", { serverId, nick }); + } +} + +export class WhoisChannelsHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const nick = msg.params[1]; + const channels = msg.params.slice(2).join(" "); + this.emit("WHOIS_CHANNELS", { serverId, nick, channels }); + } +} + +export class WhoisSpecialHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const nick = msg.params[1]; + const message = msg.params.slice(2).join(" "); + this.emit("WHOIS_SPECIAL", { serverId, nick, message }); + } +} + +export class ListChannelHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const channelName = msg.params[1]; + const userCount = msg.params[2] ? Number.parseInt(msg.params[2], 10) : 0; + const topic = msg.params.slice(3).join(" "); + this.emit("LIST_CHANNEL", { + serverId, + channel: channelName, + userCount, + topic, + }); + } +} + +export class ListEndHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + this.emit("LIST_END", { serverId }); + } +} + +export class ChannelModeIsHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const channelName = msg.params[1]; + const modestring = msg.params[2] || ""; + const modeargs = msg.params.slice(3); + this.emit("RPL_CHANNELMODEIS", { + serverId, + channelName, + modestring, + modeargs, + }); + } +} + +export class WhoisAccountHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const nick = msg.params[1]; + const account = msg.params[2]; + this.emit("WHOIS_ACCOUNT", { serverId, nick, account }); + } +} + +export class NoTopicHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const channelName = msg.params[1]; + this.emit("RPL_NOTOPIC", { serverId, channelName }); + } +} + +export class RplTopicHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const channelName = msg.params[1]; + const topic = msg.params.slice(2).join(" "); + this.emit("RPL_TOPIC", { serverId, channelName, topic }); + } +} + +export class TopicWhoTimeHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const channelName = msg.params[1]; + const setter = msg.params[2]; + const timestamp = Number.parseInt(msg.params[3], 10); + this.emit("RPL_TOPICWHOTIME", { serverId, channelName, setter, timestamp }); + } +} + +export class WhoisBotHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const nick = msg.params[0]; + const target = msg.params[1]; + const message = msg.params.slice(2).join(" "); + this.emit("WHOIS_BOT", { serverId, nick, target, message }); + } +} + +export class InviteListHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const channel = msg.params[1]; + const mask = msg.params[2]; + const setter = msg.params[3]; + const timestamp = Number.parseInt(msg.params[4], 10); + this.emit("RPL_INVITELIST", { serverId, channel, mask, setter, timestamp }); + } +} + +export class EndOfInviteListHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const channel = msg.params[1]; + this.emit("RPL_ENDOFINVITELIST", { serverId, channel }); + } +} + +export class ExceptListHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const channel = msg.params[1]; + const mask = msg.params[2]; + const setter = msg.params[3]; + const timestamp = Number.parseInt(msg.params[4], 10); + this.emit("RPL_EXCEPTLIST", { serverId, channel, mask, setter, timestamp }); + } +} + +export class EndOfExceptListHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const channel = msg.params[1]; + this.emit("RPL_ENDOFEXCEPTLIST", { serverId, channel }); + } +} + +export class WhoReplyHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const channel = msg.params[1]; + const username = msg.params[2]; + const host = msg.params[3]; + const server = msg.params[4]; + const nick = msg.params[5]; + const flags = msg.params[6]; + const trailing = msg.params[7] || ""; + const spaceIndex = trailing.indexOf(" "); + let hopcount = trailing; + let realname = ""; + if (spaceIndex !== -1) { + hopcount = trailing.substring(0, spaceIndex); + realname = trailing.substring(spaceIndex + 1); + } + this.emit("WHO_REPLY", { + serverId, + channel, + username, + host, + server, + nick, + flags, + hopcount, + realname, + }); + } +} + +export class NamesHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const channelName = msg.params[2]; + const namesStr = msg.params.slice(3).join(" ").trim(); + const names = namesStr.startsWith(":") ? namesStr.substring(1) : namesStr; + const newUsers = parseNamesResponse(names); + + this.emit("NAMES", { serverId, channelName, users: newUsers }); + } +} + +export class WhoxReplyHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const channel = msg.params[1]; + const username = msg.params[2]; + const host = msg.params[3]; + const nick = msg.params[4]; + const flags = msg.params[5]; + const account = msg.params[6]; + const opLevelField = msg.params[7] || ""; + const realname = msg.params[8] || ""; + const isAway = flags.includes("G"); + let opLevel = ""; + if (flags.length > 1) { + const statusPart = flags.substring(1); + opLevel = statusPart + .split("") + .filter((char) => ["@", "+", "~", "%", "&"].includes(char)) + .join(""); + } + this.emit("WHOX_REPLY", { + serverId, + channel, + username, + host, + nick, + account, + flags, + realname, + isAway, + opLevel, + }); + } +} + +export class BanListHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const channel = msg.params[1]; + const mask = msg.params[2]; + const setter = msg.params[3]; + const timestamp = Number.parseInt(msg.params[4], 10); + this.emit("RPL_BANLIST", { serverId, channel, mask, setter, timestamp }); + } +} + +export class EndOfBanListHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const channel = msg.params[1]; + this.emit("RPL_ENDOFBANLIST", { serverId, channel }); + } +} + +export class YoureOperHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const message = msg.params.slice(1).join(" "); + this.emit("RPL_YOUREOPER", { serverId, message }); + } +} + +export class NickErrorHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const code = msg.command; + const nick = msg.params[1]; + const message = msg.params.slice(2).join(" "); + const errorMap: Record = { + "431": "No nickname given", + "432": "Invalid nickname", + "433": "Nickname already in use", + "436": "Nickname collision", + }; + this.emit("NICK_ERROR", { + serverId, + code, + error: errorMap[code] || "Unknown error", + nick: code === "431" ? undefined : nick, + message, + }); + } +} + +export class WhoisSecureHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const nick = msg.params[1]; + const message = msg.params.slice(2).join(" "); + this.emit("WHOIS_SECURE", { serverId, nick, message }); + } +} + +export class MonOnlineHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const targetList = msg.params.slice(1).join(" "); + const cleanTargetList = targetList.startsWith(":") + ? targetList.substring(1) + : targetList; + const targets = cleanTargetList.split(",").map((target) => { + const parts = target.split("!"); + if (parts.length === 2) { + const [nick, userhost] = parts; + const [user, host] = userhost.split("@"); + return { nick, user, host }; + } + return { nick: target }; + }); + this.emit("MONONLINE", { serverId, targets }); + } +} + +export class MonOfflineHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const targetList = msg.params.slice(1).join(" "); + const cleanTargetList = targetList.startsWith(":") + ? targetList.substring(1) + : targetList; + const targets = cleanTargetList.split(","); + this.emit("MONOFFLINE", { serverId, targets }); + } +} + +export class MonListHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const targetList = msg.params.slice(1).join(" "); + const cleanTargetList = targetList.startsWith(":") + ? targetList.substring(1) + : targetList; + const targets = cleanTargetList.split(","); + this.emit("MONLIST", { serverId, targets }); + } +} + +export class EndOfMonListHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + this.emit("ENDOFMONLIST", { serverId }); + } +} + +export class MonListFullHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const limit = Number.parseInt(msg.params[1], 10); + const targetList = msg.params[2]; + const targets = targetList.split(","); + this.emit("MONLISTFULL", { serverId, limit, targets }); + } +} + +export class WhoisKeyValueHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const target = msg.params[0]; + const key = msg.params[1]; + const visibility = msg.params[2]; + const value = msg.params.slice(3).join(" "); + this.emit("METADATA_WHOIS", { serverId, target, key, visibility, value }); + } +} + +export class KeyValueHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const target = msg.params[1]; + let key = msg.params[2]; + let visibility = msg.params[3]; + let valueStartIndex = 4; + + if (msg.params[1] === msg.params[2] && msg.params.length > 5) { + key = msg.params[3]; + visibility = msg.params[4]; + valueStartIndex = 5; + } + + const value = msg.params.slice(valueStartIndex).join(" "); + const cleanValue = value.startsWith(":") ? value.substring(1) : value; + + this.emit("METADATA_KEYVALUE", { + serverId, + target, + key, + visibility, + value: cleanValue, + }); + } +} + +export class KeyNotSetHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const target = msg.params[0]; + const key = msg.params[1]; + this.emit("METADATA_KEYNOTSET", { serverId, target, key }); + } +} + +export class MetadataSubOkHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const keys = msg.params + .slice(1) + .map((key) => (key.startsWith(":") ? key.substring(1) : key)); + this.emit("METADATA_SUBOK", { serverId, keys }); + } +} + +export class MetadataUnsubOkHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const keys = msg.params + .slice(1) + .map((key) => (key.startsWith(":") ? key.substring(1) : key)); + this.emit("METADATA_UNSUBOK", { serverId, keys }); + } +} + +export class MetadataSubsHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const keys = msg.params + .slice(1) + .map((key) => (key.startsWith(":") ? key.substring(1) : key)); + this.emit("METADATA_SUBS", { serverId, keys }); + } +} + +export class MetadataSyncLaterHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const target = msg.params[0]; + const retryAfter = msg.params[1] + ? Number.parseInt(msg.params[1], 10) + : undefined; + this.emit("METADATA_SYNCLATER", { serverId, target, retryAfter }); + } +} diff --git a/src/lib/irc/handlers/standardReplyHandlers.ts b/src/lib/irc/handlers/standardReplyHandlers.ts new file mode 100644 index 00000000..a5ec1598 --- /dev/null +++ b/src/lib/irc/handlers/standardReplyHandlers.ts @@ -0,0 +1,86 @@ +import type { ParsedMessage } from "../protocol/messageParser"; +import { BaseHandler } from "./baseHandler"; + +/** + * Handles FAIL command (IRCv3 standard replies) + */ +export class FailHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const cmd = msg.params[0]; + const code = msg.params[1]; + const target = msg.params[2] || undefined; + const message = msg.params.slice(3).join(" "); + + this.emit("FAIL", { + serverId, + mtags: msg.tags, + command: cmd, + code, + target, + message, + }); + } +} + +/** + * Handles WARN command (IRCv3 standard replies) + */ +export class WarnHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const cmd = msg.params[0]; + const code = msg.params[1]; + const target = msg.params[2] || undefined; + const message = msg.params.slice(3).join(" "); + + this.emit("WARN", { + serverId, + mtags: msg.tags, + command: cmd, + code, + target, + message, + }); + } +} + +/** + * Handles NOTE command (IRCv3 standard replies) + */ +export class NoteHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const cmd = msg.params[0]; + const code = msg.params[1]; + const target = msg.params[2] || undefined; + const message = msg.params.slice(3).join(" "); + + this.emit("NOTE", { + serverId, + mtags: msg.tags, + command: cmd, + code, + target, + message, + }); + } +} + +/** + * Handles SUCCESS command (IRCv3 standard replies) + */ +export class SuccessHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const cmd = msg.params[0]; + const code = msg.params[1]; + const target = msg.params[2] || undefined; + const message = msg.params.slice(3).join(" "); + + this.emit("SUCCESS", { + serverId, + mtags: msg.tags, + command: cmd, + code, + target, + message, + }); + } +} diff --git a/src/lib/irc/handlers/userHandlers.ts b/src/lib/irc/handlers/userHandlers.ts new file mode 100644 index 00000000..73d0d55f --- /dev/null +++ b/src/lib/irc/handlers/userHandlers.ts @@ -0,0 +1,145 @@ +import type { ParsedMessage } from "../protocol/messageParser"; +import { getNickFromNuh } from "../utils/ircUtils"; +import { BaseHandler } from "./baseHandler"; + +export class NickHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const oldNick = getNickFromNuh(msg.source); + let newNick = msg.params[0]; + + if (newNick.startsWith(":")) { + newNick = newNick.substring(1); + } + + if (oldNick === this.getNick(serverId)) { + this.stateManager.setNick(serverId, newNick); + this.stateManager.updateCurrentUser(serverId, { username: newNick }); + } + + this.emit("NICK", { + serverId, + mtags: msg.tags, + oldNick, + newNick, + }); + } +} + +export class JoinHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const username = getNickFromNuh(msg.source); + const channelName = + msg.params[0][0] === ":" ? msg.params[0].substring(1) : msg.params[0]; + + let account: string | undefined; + let realname: string | undefined; + + if (msg.params.length >= 2) { + account = msg.params[1] === "*" ? undefined : msg.params[1]; + if (msg.params.length >= 3) { + realname = msg.params.slice(2).join(" "); + } + } + + this.emit("JOIN", { + serverId, + username, + channelName, + batchTag: msg.tags?.batch, + account, + realname, + }); + } +} + +export class PartHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const username = getNickFromNuh(msg.source); + const channelName = msg.params[0]; + const reason = msg.params.slice(1).join(" ").trim(); + + this.emit("PART", { + serverId, + username, + channelName, + reason: reason || undefined, + }); + } +} + +export class QuitHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const username = getNickFromNuh(msg.source); + const reason = msg.params.join(" "); + + this.emit("QUIT", { + serverId, + username, + reason, + batchTag: msg.tags?.batch, + }); + } +} + +export class KickHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const username = getNickFromNuh(msg.source); + const channelName = msg.params[0]; + const target = msg.params[1]; + const reason = msg.params.slice(2).join(" "); + + this.emit("KICK", { + serverId, + mtags: msg.tags, + username, + channelName, + target, + reason, + }); + } +} + +export class AwayHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const username = getNickFromNuh(msg.source); + const awayMessage = + msg.params.length > 0 ? msg.params.join(" ") : undefined; + + this.emit("AWAY", { + serverId, + username, + awayMessage, + }); + } +} + +export class ChghostHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const username = getNickFromNuh(msg.source); + const newUser = msg.params[0]; + const newHost = msg.params[1]; + + this.emit("CHGHOST", { + serverId, + username, + newUser, + newHost, + }); + } +} + +export class InviteHandler extends BaseHandler { + handle(msg: ParsedMessage, serverId: string): void { + const inviter = getNickFromNuh(msg.source); + const target = msg.params[0]; + const channel = msg.params[1]; + + this.emit("INVITE", { + serverId, + mtags: msg.tags, + inviter, + target, + channel, + }); + } +} diff --git a/src/lib/irc/index.ts b/src/lib/irc/index.ts new file mode 100644 index 00000000..26e05805 --- /dev/null +++ b/src/lib/irc/index.ts @@ -0,0 +1,940 @@ +import type { Channel, Server, User } from "../../types"; +import { CapabilityNegotiator } from "./capabilities/capabilityNegotiator"; +import { MetadataManager } from "./capabilities/metadata"; +import { SaslAuthenticator } from "./capabilities/sasl"; +import { ConnectionManager } from "./core/connection"; +import { PingManager } from "./core/ping"; +import { ReconnectionManager } from "./core/reconnection"; +import { StateManager } from "./core/state"; +import { EventEmitter } from "./events/eventEmitter"; +import { BatchHandler } from "./handlers/batchHandlers"; +import { + ModeHandler, + RenameHandler, + SetnameHandler, + TopicHandler, +} from "./handlers/channelHandlers"; +import { + NoticeHandler, + PrivmsgHandler, + RedactHandler, + TagmsgHandler, +} from "./handlers/messageHandlers"; +import { + BanListHandler, + ChannelModeIsHandler, + EndOfBanListHandler, + EndOfExceptListHandler, + EndOfInviteListHandler, + EndOfMonListHandler, + EndOfWhoHandler, + EndOfWhoisHandler, + ExceptListHandler, + InviteListHandler, + IsupportHandler, + KeyNotSetHandler, + KeyValueHandler, + ListChannelHandler, + ListEndHandler, + MetadataSubOkHandler, + MetadataSubsHandler, + MetadataSyncLaterHandler, + MetadataUnsubOkHandler, + MonListHandler, + MonOfflineHandler, + MonOnlineHandler, + NamesHandler, + NickErrorHandler, + NoTopicHandler, + RplAwayHandler, + RplNowawayHandler, + RplTopicHandler, + RplUnawayHandler, + TopicWhoTimeHandler, + WelcomeHandler, + WhoisAccountHandler, + WhoisBotHandler, + WhoisChannelsHandler, + WhoisIdleHandler, + WhoisKeyValueHandler, + WhoisSecureHandler, + WhoisServerHandler, + WhoisSpecialHandler, + WhoisUserHandler, + WhoReplyHandler, + WhoxReplyHandler, + YoureOperHandler, + YourHostHandler, +} from "./handlers/numericHandlers"; +import { + FailHandler, + NoteHandler, + SuccessHandler, + WarnHandler, +} from "./handlers/standardReplyHandlers"; +// Import all handlers +import { + AwayHandler, + ChghostHandler, + InviteHandler, + JoinHandler, + KickHandler, + NickHandler, + PartHandler, + QuitHandler, +} from "./handlers/userHandlers"; +import { CommandRouter } from "./protocol/commandRouter"; +import { MessageBuilder } from "./protocol/messageBuilder"; +import { MessageParser, type ParsedMessage } from "./protocol/messageParser"; +import type { EventCallback, EventKey, EventMap } from "./types"; + +declare const __APP_VERSION__: string; + +/** + * Main IRC Client facade that orchestrates all modules + */ +export class IRCClient { + // Core modules + private eventEmitter: EventEmitter; + private stateManager: StateManager; + private connectionManager: ConnectionManager; + private reconnectionManager: ReconnectionManager; + private pingManager: PingManager; + + // Protocol modules + private messageParser: MessageParser; + private commandRouter: CommandRouter; + private messageBuilder: MessageBuilder; + + // Capability modules + private capabilityNegotiator: CapabilityNegotiator; + private saslAuthenticator: SaslAuthenticator; + private metadataManager: MetadataManager; + + public version = __APP_VERSION__; + + constructor() { + this.eventEmitter = new EventEmitter(); + this.stateManager = new StateManager(); + this.messageBuilder = new MessageBuilder(); + this.messageParser = new MessageParser(); + this.commandRouter = new CommandRouter(); + this.connectionManager = new ConnectionManager(this.eventEmitter); + this.reconnectionManager = new ReconnectionManager(this.eventEmitter); + this.pingManager = new PingManager(); + this.capabilityNegotiator = new CapabilityNegotiator( + this.eventEmitter, + this.stateManager, + this.sendRaw.bind(this), + this.userOnConnect.bind(this), + ); + this.saslAuthenticator = new SaslAuthenticator( + this.eventEmitter, + this.sendRaw.bind(this), + ); + this.metadataManager = new MetadataManager( + this.eventEmitter, + this.sendRaw.bind(this), + (serverId: string) => this.stateManager.getNick(serverId), + ); + this.registerHandlers(); + } + + /** + * Register all IRC command handlers with the CommandRouter + */ + private registerHandlers(): void { + const CLASS_BASED_HANDLERS = [ + { cmd: "NICK", Handler: NickHandler }, + { cmd: "JOIN", Handler: JoinHandler }, + { cmd: "PART", Handler: PartHandler }, + { cmd: "QUIT", Handler: QuitHandler }, + { cmd: "KICK", Handler: KickHandler }, + { cmd: "AWAY", Handler: AwayHandler }, + { cmd: "CHGHOST", Handler: ChghostHandler }, + { cmd: "INVITE", Handler: InviteHandler }, + { cmd: "PRIVMSG", Handler: PrivmsgHandler }, + { cmd: "NOTICE", Handler: NoticeHandler }, + { cmd: "TAGMSG", Handler: TagmsgHandler }, + { cmd: "REDACT", Handler: RedactHandler }, + { cmd: "MODE", Handler: ModeHandler }, + { cmd: "TOPIC", Handler: TopicHandler }, + { cmd: "RENAME", Handler: RenameHandler }, + { cmd: "SETNAME", Handler: SetnameHandler }, + { cmd: "BATCH", Handler: BatchHandler }, + { cmd: "FAIL", Handler: FailHandler }, + { cmd: "WARN", Handler: WarnHandler }, + { cmd: "NOTE", Handler: NoteHandler }, + { cmd: "SUCCESS", Handler: SuccessHandler }, + { cmd: "001", Handler: WelcomeHandler }, + { cmd: "002", Handler: YourHostHandler }, + { cmd: "005", Handler: IsupportHandler }, + { cmd: "381", Handler: YoureOperHandler }, + { cmd: "301", Handler: RplAwayHandler }, + { cmd: "305", Handler: RplUnawayHandler }, + { cmd: "306", Handler: RplNowawayHandler }, + { cmd: "311", Handler: WhoisUserHandler }, + { cmd: "312", Handler: WhoisServerHandler }, + { cmd: "317", Handler: WhoisIdleHandler }, + { cmd: "318", Handler: EndOfWhoisHandler }, + { cmd: "319", Handler: WhoisChannelsHandler }, + { cmd: "320", Handler: WhoisSpecialHandler }, + { cmd: "378", Handler: WhoisSpecialHandler }, + { cmd: "379", Handler: WhoisSpecialHandler }, + { cmd: "330", Handler: WhoisAccountHandler }, + { cmd: "671", Handler: WhoisSecureHandler }, + { cmd: "335", Handler: WhoisBotHandler }, + { cmd: "353", Handler: NamesHandler }, + { cmd: "331", Handler: NoTopicHandler }, + { cmd: "332", Handler: RplTopicHandler }, + { cmd: "333", Handler: TopicWhoTimeHandler }, + { cmd: "324", Handler: ChannelModeIsHandler }, + { cmd: "367", Handler: BanListHandler }, + { cmd: "368", Handler: EndOfBanListHandler }, + { cmd: "348", Handler: ExceptListHandler }, + { cmd: "349", Handler: EndOfExceptListHandler }, + { cmd: "346", Handler: InviteListHandler }, + { cmd: "347", Handler: EndOfInviteListHandler }, + { cmd: "352", Handler: WhoReplyHandler }, + { cmd: "354", Handler: WhoxReplyHandler }, + { cmd: "315", Handler: EndOfWhoHandler }, + { cmd: "322", Handler: ListChannelHandler }, + { cmd: "323", Handler: ListEndHandler }, + { cmd: "431", Handler: NickErrorHandler }, + { cmd: "432", Handler: NickErrorHandler }, + { cmd: "433", Handler: NickErrorHandler }, + { cmd: "436", Handler: NickErrorHandler }, + { cmd: "730", Handler: MonOnlineHandler }, + { cmd: "731", Handler: MonOfflineHandler }, + { cmd: "732", Handler: MonListHandler }, + { cmd: "733", Handler: EndOfMonListHandler }, + { cmd: "760", Handler: WhoisKeyValueHandler }, + { cmd: "761", Handler: KeyValueHandler }, + { cmd: "766", Handler: KeyNotSetHandler }, + { cmd: "770", Handler: MetadataSubOkHandler }, + { cmd: "771", Handler: MetadataUnsubOkHandler }, + { cmd: "772", Handler: MetadataSubsHandler }, + { cmd: "774", Handler: MetadataSyncLaterHandler }, + ] as const; + + CLASS_BASED_HANDLERS.forEach(({ cmd, Handler }) => { + this.commandRouter.registerHandler( + cmd, + new Handler(this.eventEmitter, this.stateManager), + ); + }); + + // SASL responses - inline handlers + this.commandRouter.registerHandler("900", { + handle: (msg, serverId) => { + // RPL_LOGGEDIN: You are now logged in as + const message = msg.params.slice(2).join(" "); + this.saslAuthenticator.handleSuccess(serverId); + }, + }); + + this.commandRouter.registerHandler("901", { + handle: (msg, serverId) => { + // SASL authentication successful + this.saslAuthenticator.handleSuccess(serverId); + this.sendRaw(serverId, "CAP END"); + this.stateManager.setCapNegotiationComplete(serverId, true); + this.userOnConnect(serverId); + }, + }); + + this.commandRouter.registerHandler("902", { + handle: (msg, serverId) => { + // SASL authentication successful + this.saslAuthenticator.handleSuccess(serverId); + this.sendRaw(serverId, "CAP END"); + this.stateManager.setCapNegotiationComplete(serverId, true); + this.userOnConnect(serverId); + }, + }); + + this.commandRouter.registerHandler("903", { + handle: (msg, serverId) => { + // SASL authentication successful + this.saslAuthenticator.handleSuccess(serverId); + this.sendRaw(serverId, "CAP END"); + this.stateManager.setCapNegotiationComplete(serverId, true); + this.userOnConnect(serverId); + }, + }); + + this.commandRouter.registerHandler("904", { + handle: (msg, serverId) => { + // SASL authentication failed + const message = msg.params.slice(2).join(" "); + this.saslAuthenticator.handleFailure(serverId, "904", message); + this.sendRaw(serverId, "CAP END"); + this.stateManager.setCapNegotiationComplete(serverId, true); + this.userOnConnect(serverId); + }, + }); + + this.commandRouter.registerHandler("905", { + handle: (msg, serverId) => { + // SASL authentication failed + const message = msg.params.slice(2).join(" "); + this.saslAuthenticator.handleFailure(serverId, "905", message); + this.sendRaw(serverId, "CAP END"); + this.stateManager.setCapNegotiationComplete(serverId, true); + this.userOnConnect(serverId); + }, + }); + + this.commandRouter.registerHandler("906", { + handle: (msg, serverId) => { + // SASL authentication failed + const message = msg.params.slice(2).join(" "); + this.saslAuthenticator.handleFailure(serverId, "906", message); + this.sendRaw(serverId, "CAP END"); + this.stateManager.setCapNegotiationComplete(serverId, true); + this.userOnConnect(serverId); + }, + }); + + this.commandRouter.registerHandler("907", { + handle: (msg, serverId) => { + // SASL authentication failed + const message = msg.params.slice(2).join(" "); + this.saslAuthenticator.handleFailure(serverId, "907", message); + this.sendRaw(serverId, "CAP END"); + this.stateManager.setCapNegotiationComplete(serverId, true); + this.userOnConnect(serverId); + }, + }); + + this.commandRouter.registerHandler("CAP", { + handle: (msg, serverId) => this.handleCapCommand(msg, serverId), + }); + + this.commandRouter.registerHandler("PING", { + handle: (msg, serverId) => { + const key = msg.params.join(" "); + this.sendRaw(serverId, `PONG :${key}`); + }, + }); + + this.commandRouter.registerHandler("PONG", { + handle: (_msg, serverId) => { + this.pingManager.handlePong(serverId); + }, + }); + + this.commandRouter.registerHandler("ERROR", { + handle: (msg, serverId) => { + const errorMessage = msg.params.join(" "); + console.log(`IRC ERROR from server ${serverId}: ${errorMessage}`); + }, + }); + + this.commandRouter.registerHandler("AUTHENTICATE", { + handle: (msg, serverId) => { + const param = msg.params.join(" "); + this.eventEmitter.triggerEvent("AUTHENTICATE", { serverId, param }); + this.saslAuthenticator.handleAuthenticateResponse(serverId, param); + }, + }); + + this.commandRouter.registerHandler("EXTJWT", { + handle: (msg, serverId) => { + const requestedTarget = msg.params[0]; + const serviceName = msg.params[1]; + let jwtToken: string; + if (msg.params[2] === "*") { + jwtToken = msg.params[3]; + } else { + jwtToken = msg.params[2]; + } + this.eventEmitter.triggerEvent("EXTJWT", { + serverId, + requestedTarget, + serviceName, + jwtToken, + }); + }, + }); + } + + /** + * Handle CAP command with special logic for negotiation + */ + private handleCapCommand(msg: ParsedMessage, serverId: string): void { + let i = 0; + let caps = ""; + + if (msg.params[i] === "*") { + i++; + } + + let subcommand = msg.params[i++]; + if ( + subcommand !== "LS" && + subcommand !== "ACK" && + subcommand !== "NEW" && + subcommand !== "DEL" && + subcommand !== "NAK" + ) { + // This is likely a nickname, skip it and get the real subcommand + subcommand = msg.params[i++]; + } + + const isFinal = subcommand === "LS" && msg.params[i] !== "*"; + if (msg.params[i] === "*") i++; + + // Build caps string from remaining params + while (msg.params[i]) { + caps += msg.params[i++]; + if (msg.params[i]) caps += " "; + } + + // Route to capability negotiator + if (subcommand === "LS") { + this.capabilityNegotiator.handleCapLs(serverId, caps, isFinal); + } else if (subcommand === "ACK") { + this.capabilityNegotiator.handleCapAck(serverId, caps); + } else if (subcommand === "NAK") { + this.capabilityNegotiator.handleCapNak(serverId); + } else if (subcommand === "NEW") { + this.capabilityNegotiator.handleCapNew(serverId, caps); + } else if (subcommand === "DEL") { + this.capabilityNegotiator.handleCapDel(serverId, caps); + } + } + + /** + * Handle incoming IRC messages + */ + private handleMessage(data: string, serverId: string): void { + const server = this.stateManager.getServer(serverId); + if (!server) { + console.error(`Server ${serverId} not found`); + return; + } + + const serverName = server.name; + const messages = this.messageParser.parseMultiple(data, serverName); + + for (const message of messages) { + console.log( + `IRC Client: Received command ${message.command} from server ${serverId}`, + ); + + // Route message to appropriate handler + const handled = this.commandRouter.route(message, serverId); + if (!handled) { + console.warn(`No handler registered for command: ${message.command}`); + } + } + } + + /** + * Called after user registration (NICK/USER) to complete connection + */ + userOnConnect(serverId: string): void { + const nick = this.stateManager.getNick(serverId); + if (!nick) { + console.warn(`No nick found for server ${serverId} in userOnConnect`); + return; + } + + // NICK is already sent before CAP negotiation, only send USER now + this.sendRaw(serverId, `USER ${nick} 0 * :${nick}`); + } + + // ==================== PUBLIC API ==================== + + /** + * Connect to an IRC server + */ + async connect( + name: string, + host: string, + port: number, + nickname: string, + password?: string, + saslAccountName?: string, + saslPassword?: string, + serverId?: string, + ): Promise { + const actualServerId = serverId || `${host}:${port}`; + + const existingServer = this.stateManager.getServer(actualServerId); + if (existingServer?.isConnected) { + return existingServer; + } + + if (saslAccountName && saslPassword) { + this.saslAuthenticator.setCredentials( + actualServerId, + saslAccountName, + saslPassword, + ); + this.stateManager.setSaslEnabled(actualServerId, true); + } + + const server: Server = { + id: actualServerId, + name, + host, + port, + channels: [], + privateChats: [], + isConnected: false, + connectionState: "connecting", + users: [], + }; + + this.stateManager.addServer(actualServerId, server); + this.stateManager.setNick(actualServerId, nickname); + + // Initialize current user for this server + this.stateManager.setCurrentUser(actualServerId, { + id: `${actualServerId}-${nickname}`, + username: nickname, + isOnline: true, + status: "online", + }); + + await this.connectionManager.connect( + host, + port, + actualServerId, + (socket: WebSocket) => { + server.isConnected = true; + server.connectionState = "connected"; + + this.sendRaw(actualServerId, "CAP LS 302"); + + this.sendRaw(actualServerId, `NICK ${nickname}`); + if (password) { + this.sendRaw(actualServerId, `PASS ${password}`); + } + + this.pingManager.startPing(actualServerId, (msg: string) => { + this.sendRaw(actualServerId, msg); + }); + }, + (data: string, sid: string) => { + // On message + this.handleMessage(data, sid); + }, + () => { + // On close + server.isConnected = false; + server.connectionState = "disconnected"; + this.pingManager.stopPing(actualServerId); + + // Start reconnection + this.reconnectionManager.startReconnection(actualServerId, async () => { + await this.connect( + name, + host, + port, + nickname, + password, + saslAccountName, + saslPassword, + actualServerId, + ); + }); + }, + () => { + // On error + server.isConnected = false; + server.connectionState = "disconnected"; + }, + ); + + return server; + } + + /** + * Disconnect from an IRC server + */ + disconnect(serverId: string, quitMessage?: string): void { + this.connectionManager.disconnect(serverId, quitMessage); + this.reconnectionManager.clearReconnection(serverId); + this.pingManager.stopPing(serverId); + } + + /** + * Send raw IRC command to server + */ + sendRaw(serverId: string, command: string): void { + this.connectionManager.sendRaw(serverId, command); + } + + /** + * Send a message to a channel or user + */ + sendMessage( + serverId: string, + target: string, + message: string, + tags?: Record, + ): void { + const command = this.messageBuilder.buildPrivmsg(target, message, tags); + this.sendRaw(serverId, command); + } + + /** + * Send a multiline message + */ + sendMultilineMessage( + serverId: string, + target: string, + lines: string[], + batchId?: string, + ): void { + const commands = this.messageBuilder.buildMultiline(target, lines, batchId); + for (const cmd of commands) { + this.sendRaw(serverId, cmd); + } + } + + /** + * Join a channel + */ + joinChannel(serverId: string, channelName: string, key?: string): Channel { + const server = this.stateManager.getServer(serverId); + if (!server) { + throw new Error(`Server with ID ${serverId} not found`); + } + + const existing = server.channels.find((c) => c.name === channelName); + if (existing) return existing; + + const command = key ? `JOIN ${channelName} ${key}` : `JOIN ${channelName}`; + this.sendRaw(serverId, command); + + if (server.capabilities?.includes("draft/chathistory")) { + this.sendRaw(serverId, `CHATHISTORY LATEST ${channelName} * 50`); + } + + const channel: Channel = { + id: crypto.randomUUID(), + name: channelName, + topic: "", + isPrivate: false, + serverId, + unreadCount: 0, + isMentioned: false, + messages: [], + users: [], + isLoadingHistory: !!server.capabilities?.includes("draft/chathistory"), + needsWhoRequest: true, + chathistoryRequested: + !!server.capabilities?.includes("draft/chathistory"), + }; + + server.channels.push(channel); + + // Trigger event to notify store that history loading started + if (server.capabilities?.includes("draft/chathistory")) { + this.triggerEvent("CHATHISTORY_LOADING", { + serverId, + channelName, + isLoading: true, + }); + } + + return channel; + } + + /** + * Part a channel + */ + partChannel(serverId: string, channelName: string, reason?: string): void { + const command = reason + ? `PART ${channelName} :${reason}` + : `PART ${channelName}`; + this.sendRaw(serverId, command); + } + + /** + * Send a WHOIS query + */ + whois(serverId: string, target: string): void { + this.sendRaw(serverId, `WHOIS ${target}`); + } + + /** + * Send a WHO query + */ + who(serverId: string, target: string): void { + this.sendRaw(serverId, `WHO ${target}`); + } + + /** + * Set channel topic + */ + setTopic(serverId: string, channelName: string, topic: string): void { + this.sendRaw(serverId, `TOPIC ${channelName} :${topic}`); + } + + /** + * Request list of channels from server + */ + listChannels( + serverId: string, + elist?: string, + filters?: { + minUsers?: number; + maxUsers?: number; + minCreationTime?: number; + maxCreationTime?: number; + minTopicTime?: number; + maxTopicTime?: number; + mask?: string; + notMask?: string; + }, + ): void { + let command = "LIST"; + + if (elist && filters) { + // Build LIST parameters based on filters and available ELIST capabilities + const elistTokens = elist.toUpperCase().split(""); + const params: string[] = []; + + // User count filtering (U extension) + if (elistTokens.includes("U")) { + if (filters.minUsers && filters.minUsers > 0) { + params.push(`>${filters.minUsers}`); + } + if (filters.maxUsers && filters.maxUsers > 0) { + params.push(`<${filters.maxUsers}`); + } + } + + // Creation time filtering (C extension) + if (elistTokens.includes("C")) { + if (filters.minCreationTime && filters.minCreationTime > 0) { + params.push(`C>${filters.minCreationTime}`); + } + if (filters.maxCreationTime && filters.maxCreationTime > 0) { + params.push(`C<${filters.maxCreationTime}`); + } + } + + // Topic time filtering (T extension) + if (elistTokens.includes("T")) { + if (filters.minTopicTime && filters.minTopicTime > 0) { + params.push(`T>${filters.minTopicTime}`); + } + if (filters.maxTopicTime && filters.maxTopicTime > 0) { + params.push(`T<${filters.maxTopicTime}`); + } + } + + // Mask filtering (M extension) + if (elistTokens.includes("M") && filters.mask) { + params.push(filters.mask); + } + + // Non-matching mask filtering (N extension) + if (elistTokens.includes("N") && filters.notMask) { + params.push(`!${filters.notMask}`); + } + + if (params.length > 0) { + command = `LIST ${params.join(" ")}`; + } + } + + this.sendRaw(serverId, command); + } + + /** + * Rename a channel + */ + renameChannel( + serverId: string, + oldName: string, + newName: string, + reason?: string, + ): void { + const command = reason + ? `RENAME ${oldName} ${newName} :${reason}` + : `RENAME ${oldName} ${newName}`; + this.sendRaw(serverId, command); + } + + /** + * Set realname + */ + setName(serverId: string, realname: string): void { + this.sendRaw(serverId, `SETNAME :${realname}`); + } + + /** + * Change nickname + */ + changeNick(serverId: string, newNick: string): void { + this.sendRaw(serverId, `NICK ${newNick}`); + } + + // ==================== METADATA COMMANDS ==================== + + metadataGet(serverId: string, target: string, keys: string[]): void { + this.metadataManager.get(serverId, target, keys); + } + + metadataList(serverId: string, target: string): void { + this.metadataManager.list(serverId, target); + } + + metadataSet( + serverId: string, + target: string, + key: string, + value?: string, + visibility?: string, + ): void { + this.metadataManager.set(serverId, target, key, value, visibility); + } + + metadataClear(serverId: string, target: string): void { + this.metadataManager.clear(serverId, target); + } + + metadataSub(serverId: string, keys: string[]): void { + this.metadataManager.subscribe(serverId, keys); + } + + metadataUnsub(serverId: string, keys: string[]): void { + this.metadataManager.unsubscribe(serverId, keys); + } + + metadataSubs(serverId: string): void { + this.metadataManager.listSubscriptions(serverId); + } + + metadataSync(serverId: string, target: string): void { + this.metadataManager.sync(serverId, target); + } + + // ==================== EXTJWT COMMANDS ==================== + + requestExtJwt(serverId: string, target?: string, serviceName?: string): void { + const targetParam = target || "*"; + const command = serviceName + ? `EXTJWT ${targetParam} ${serviceName}` + : `EXTJWT ${targetParam}`; + this.sendRaw(serverId, command); + } + + // ==================== MONITOR COMMANDS ==================== + + monitorAdd(serverId: string, targets: string[]): void { + const targetsStr = targets.join(","); + this.sendRaw(serverId, `MONITOR + ${targetsStr}`); + } + + monitorRemove(serverId: string, targets: string[]): void { + const targetsStr = targets.join(","); + this.sendRaw(serverId, `MONITOR - ${targetsStr}`); + } + + monitorList(serverId: string): void { + this.sendRaw(serverId, "MONITOR L"); + } + + monitorClear(serverId: string): void { + this.sendRaw(serverId, "MONITOR C"); + } + + monitorStatus(serverId: string): void { + this.sendRaw(serverId, "MONITOR S"); + } + + // ==================== EVENT HANDLERS ==================== + + on(event: K, callback: EventCallback): void { + this.eventEmitter.on(event, callback); + } + + deleteHook(event: K, callback: EventCallback): void { + this.eventEmitter.deleteHook(event, callback); + } + + // ==================== GETTERS ==================== + + getServers(): Server[] { + return this.stateManager.getServers(); + } + + getCurrentUser(serverId?: string): User | null { + if (!serverId) return null; + return this.stateManager.getCurrentUser(serverId); + } + + getAllUsers(serverId: string): User[] { + return this.stateManager.getAllUsers(serverId); + } + + getNick(serverId: string): string | undefined { + return this.stateManager.getNick(serverId); + } + + // ==================== ADDITIONAL PUBLIC METHODS ==================== + + sendTyping(serverId: string, target: string, isActive: boolean): void { + const typingState = isActive ? "active" : "done"; + this.sendRaw(serverId, `@+typing=${typingState} TAGMSG ${target}`); + } + + leaveChannel(serverId: string, channelName: string): void { + const server = this.stateManager.getServer(serverId); + if (server) { + this.sendRaw(serverId, `PART ${channelName}`); + server.channels = server.channels.filter((c) => c.name !== channelName); + } + } + + triggerEvent(event: K, data: EventMap[K]): void { + this.eventEmitter.triggerEvent(event, data); + } + + sendRedact( + serverId: string, + target: string, + msgid: string, + reason?: string, + ): void { + const command = reason + ? `REDACT ${target} ${msgid} :${reason}` + : `REDACT ${target} ${msgid}`; + this.sendRaw(serverId, command); + } + + registerAccount( + serverId: string, + account: string, + email: string, + password: string, + ): void { + this.sendRaw(serverId, `REGISTER ${account} ${email} ${password}`); + } + + verifyAccount(serverId: string, account: string, code: string): void { + this.sendRaw(serverId, `VERIFY ${account} ${code}`); + } + + markChannelAsRead(serverId: string, channelId: string): void { + const server = this.stateManager.getServer(serverId); + const channel = server?.channels.find((c) => c.id === channelId); + if (channel) channel.unreadCount = 0; + } + + capAck(serverId: string, key: string, capabilities: string): void { + this.triggerEvent("CAP_ACKNOWLEDGED", { serverId, key, capabilities }); + } + + isCapNegotiationComplete(serverId: string): boolean { + return this.stateManager.isCapNegotiationComplete(serverId); + } +} diff --git a/src/lib/irc/protocol/commandRouter.ts b/src/lib/irc/protocol/commandRouter.ts new file mode 100644 index 00000000..4808dc93 --- /dev/null +++ b/src/lib/irc/protocol/commandRouter.ts @@ -0,0 +1,107 @@ +import type { ParsedMessage } from "./messageParser"; + +export interface CommandHandler { + handle(message: ParsedMessage, serverId: string): void; +} + +/** + * Routes IRC commands to appropriate handlers + */ +export class CommandRouter { + private handlers: Map = new Map(); + private numericHandlers: Map = new Map(); + + /** + * Register a handler for a command + * @param command Command name (e.g., "PRIVMSG") or numeric code (e.g., 353) + * @param handler Handler instance + */ + registerHandler(command: string | number, handler: CommandHandler): void { + if (typeof command === "number") { + this.numericHandlers.set(command, handler); + } else { + this.handlers.set(command.toUpperCase(), handler); + } + } + + /** + * Register multiple handlers at once + */ + registerHandlers( + handlers: Array<{ command: string | number; handler: CommandHandler }>, + ): void { + for (const { command, handler } of handlers) { + this.registerHandler(command, handler); + } + } + + /** + * Route a message to its handler + * @param message Parsed IRC message + * @param serverId Server ID + * @returns true if handler was found and executed, false otherwise + */ + route(message: ParsedMessage, serverId: string): boolean { + const { command } = message; + + // Try numeric handler first + const numericCommand = Number.parseInt(command, 10); + if (!Number.isNaN(numericCommand)) { + const handler = this.numericHandlers.get(numericCommand); + if (handler) { + handler.handle(message, serverId); + return true; + } + } + + // Try string command handler + const handler = this.handlers.get(command.toUpperCase()); + if (handler) { + handler.handle(message, serverId); + return true; + } + + // No handler found + console.log(`No handler found for command: ${command}`); + return false; + } + + /** + * Check if a handler is registered for a command + */ + hasHandler(command: string | number): boolean { + if (typeof command === "number") { + return this.numericHandlers.has(command); + } + return this.handlers.has(command.toUpperCase()); + } + + /** + * Unregister a handler + */ + unregisterHandler(command: string | number): void { + if (typeof command === "number") { + this.numericHandlers.delete(command); + } else { + this.handlers.delete(command.toUpperCase()); + } + } + + /** + * Get all registered command names + */ + getRegisteredCommands(): string[] { + return [ + ...Array.from(this.handlers.keys()), + ...Array.from(this.numericHandlers.keys()).map(String), + ]; + } + + /** + * Clear all handlers + */ + clearAll(): void { + this.handlers.clear(); + this.numericHandlers.clear(); + } +} diff --git a/src/lib/irc/protocol/messageBuilder.ts b/src/lib/irc/protocol/messageBuilder.ts new file mode 100644 index 00000000..bb2e59ae --- /dev/null +++ b/src/lib/irc/protocol/messageBuilder.ts @@ -0,0 +1,124 @@ +/** + * Builds outgoing IRC protocol messages + */ +export class MessageBuilder { + /** + * Build a PRIVMSG command + */ + buildPrivmsg( + target: string, + message: string, + tags?: Record, + ): string { + let command = ""; + if (tags) { + const tagString = Object.entries(tags) + .map(([key, value]) => `${key}=${value}`) + .join(";"); + command = `@${tagString} `; + } + command += `PRIVMSG ${target} :${message}`; + return command; + } + + /** + * Build a multiline message batch + */ + buildMultiline(target: string, lines: string[], batchId?: string): string[] { + const id = + batchId || `ml_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const commands: string[] = []; + + // Start batch + commands.push(`BATCH +${id} draft/multiline ${target}`); + + // Send each line + for (const line of lines) { + const splitLines = this.splitLongLine(line); + for (const splitLine of splitLines) { + commands.push(`@batch=${id} PRIVMSG ${target} :${splitLine}`); + } + } + + // End batch + commands.push(`BATCH -${id}`); + + return commands; + } + + /** + * Split long lines to respect IRC message length limits (512 bytes) + * @param text Text to split + * @param maxLength Maximum length per line (default 450 to account for IRC overhead) + * @returns Array of split lines + */ + splitLongLine(text: string, maxLength = 450): string[] { + if (!text) return [""]; + + const lines: string[] = []; + let remaining = text; + + while (remaining.length > maxLength) { + // Try to split at word boundaries + let splitIndex = maxLength; + const lastSpace = remaining.lastIndexOf(" ", maxLength); + if (lastSpace > maxLength * 0.7) { + // Don't split too early + splitIndex = lastSpace; + } + + lines.push(remaining.substring(0, splitIndex)); + remaining = remaining.substring(splitIndex).trim(); + } + + if (remaining) { + lines.push(remaining); + } + + return lines.length > 0 ? lines : [""]; + } + + /** + * Build a TAGMSG command + */ + buildTagmsg(target: string, tags: Record): string { + const tagString = Object.entries(tags) + .map(([key, value]) => `${key}=${value}`) + .join(";"); + return `@${tagString} TAGMSG ${target}`; + } + + /** + * Build a command with optional tags + */ + buildCommand( + command: string, + params: string[], + tags?: Record, + ): string { + let cmd = ""; + if (tags) { + const tagString = Object.entries(tags) + .map(([key, value]) => `${key}=${value}`) + .join(";"); + cmd = `@${tagString} `; + } + cmd += command; + if (params.length > 0) { + // Last param needs ':' prefix if it contains spaces + const lastParam = params[params.length - 1]; + const otherParams = params.slice(0, -1); + + if (otherParams.length > 0) { + cmd += ` ${otherParams.join(" ")}`; + } + + if (lastParam.includes(" ")) { + cmd += ` :${lastParam}`; + } else { + cmd += ` ${lastParam}`; + } + } + return cmd; + } +} diff --git a/src/lib/irc/protocol/messageParser.ts b/src/lib/irc/protocol/messageParser.ts new file mode 100644 index 00000000..8d2f55c3 --- /dev/null +++ b/src/lib/irc/protocol/messageParser.ts @@ -0,0 +1,109 @@ +import { parseMessageTags } from "../utils/ircUtils"; + +export interface ParsedMessage { + tags?: Record; + source: string; + command: string; + params: string[]; +} + +/** + * Parses raw IRC protocol messages into structured format + */ +export class MessageParser { + /** + * Parse a single IRC message line + * @param line Raw IRC message line + * @param defaultSource Default source to use if message has no source prefix + * @returns Parsed message or null if invalid + */ + parse(line: string, defaultSource: string): ParsedMessage | null { + const trimmedLine = line.trim(); + + // Skip empty lines + if (!trimmedLine) return null; + + console.log(`IRC Parser: Parsing line: ${trimmedLine}`); + + let mtags: Record | undefined; + let lineAfterTags = trimmedLine; + + // Handle message tags first, before splitting on trailing parameter + if (trimmedLine[0] === "@") { + const spaceIndex = trimmedLine.indexOf(" "); + if (spaceIndex !== -1) { + mtags = parseMessageTags(trimmedLine.substring(0, spaceIndex)); + lineAfterTags = trimmedLine.substring(spaceIndex + 1); + } + } + + // Parse IRC message properly handling colon-prefixed trailing parameter + const spaceColonIndex = lineAfterTags.indexOf(" :"); + let trailing = ""; + let mainPart = lineAfterTags; + + if (spaceColonIndex !== -1) { + trailing = lineAfterTags.substring(spaceColonIndex + 2); // Skip ' :' + mainPart = lineAfterTags.substring(0, spaceColonIndex); + } + + const parts = mainPart.split(" ").filter((part) => part.length > 0); + + // Ensure we have at least one element + if (parts.length === 0) return null; + + let i = 0; + let source: string; + + // Determine the source. if none, use default + if (parts[i][0] !== ":") { + source = defaultSource; + } else { + source = parts[i].substring(1); + i++; + } + + // Get command + if (i >= parts.length) return null; + const command = parts[i]; + i++; + + // Collect parameters + const params: string[] = []; + for (; i < parts.length; i++) { + params.push(parts[i]); + } + + // Add trailing parameter if it exists + if (trailing) { + params.push(trailing); + } + + return { + tags: mtags, + source, + command, + params, + }; + } + + /** + * Parse multiple IRC messages from a data chunk + * @param data Raw data containing one or more IRC messages + * @param defaultSource Default source for messages without a source + * @returns Array of parsed messages + */ + parseMultiple(data: string, defaultSource: string): ParsedMessage[] { + const lines = data.split("\r\n"); + const messages: ParsedMessage[] = []; + + for (const line of lines) { + const message = this.parse(line, defaultSource); + if (message) { + messages.push(message); + } + } + + return messages; + } +} diff --git a/src/lib/irc/types.ts b/src/lib/irc/types.ts new file mode 100644 index 00000000..8706474f --- /dev/null +++ b/src/lib/irc/types.ts @@ -0,0 +1,367 @@ +import type { + BaseIRCEvent, + BaseMessageEvent, + BaseMetadataEvent, + BaseUserActionEvent, + Channel, + ConnectionState, + EventWithTags, + MetadataValueEvent, + Server, + User, +} from "../../types"; + +export interface EventMap { + ready: BaseIRCEvent & { serverName: string; nickname: string }; + connectionStateChange: BaseIRCEvent & { + serverId: string; + connectionState: ConnectionState; + }; + NICK: EventWithTags & { + oldNick: string; + newNick: string; + }; + QUIT: BaseUserActionEvent & { reason: string; batchTag?: string }; + JOIN: BaseUserActionEvent & { + channelName: string; + batchTag?: string; + account?: string; // From extended-join + realname?: string; // From extended-join + }; + PART: BaseUserActionEvent & { + channelName: string; + reason?: string; + }; + KICK: EventWithTags & { + username: string; + channelName: string; + target: string; + reason: string; + }; + MODE: EventWithTags & { + sender: string; + target: string; + modestring: string; + modeargs: string[]; + }; + RPL_CHANNELMODEIS: BaseIRCEvent & { + channelName: string; + modestring: string; + modeargs: string[]; + }; + CHANMSG: BaseMessageEvent & { + channelName: string; + }; + USERMSG: BaseMessageEvent & { + target: string; + }; + CHANNNOTICE: BaseMessageEvent & { + channelName: string; + }; + USERNOTICE: BaseMessageEvent; + TAGMSG: EventWithTags & { + sender: string; + channelName: string; + timestamp: Date; + }; + REDACT: EventWithTags & { + target: string; + msgid: string; + reason: string; + sender: string; + }; + NAMES: BaseIRCEvent & { channelName: string; users: User[] }; + "CAP LS": BaseIRCEvent & { cliCaps: string }; + "CAP ACK": BaseIRCEvent & { cliCaps: string }; + ISUPPORT: BaseIRCEvent & { key: string; value: string }; + CAP_ACKNOWLEDGED: BaseIRCEvent & { key: string; capabilities: string }; + CAP_END: BaseIRCEvent; + AUTHENTICATE: BaseIRCEvent & { param: string }; + METADATA: MetadataValueEvent; + METADATA_WHOIS: MetadataValueEvent; + METADATA_KEYVALUE: MetadataValueEvent; + METADATA_KEYNOTSET: BaseMetadataEvent; + METADATA_SUBOK: BaseIRCEvent & { keys: string[] }; + METADATA_UNSUBOK: BaseIRCEvent & { keys: string[] }; + METADATA_SUBS: BaseIRCEvent & { keys: string[] }; + METADATA_SYNCLATER: BaseIRCEvent & { target: string; retryAfter?: number }; + BATCH_START: BaseIRCEvent & { + batchId: string; + type: string; + parameters?: string[]; + }; + BATCH_END: BaseIRCEvent & { batchId: string }; + MULTILINE_MESSAGE: BaseMessageEvent & { + channelName?: string; + lines: string[]; + messageIds: string[]; + }; + METADATA_FAIL: BaseIRCEvent & { + subcommand: string; + code: string; + target?: string; + key?: string; + retryAfter?: number; + }; + LIST_CHANNEL: { + serverId: string; + channel: string; + userCount: number; + topic: string; + }; + LIST_END: { serverId: string }; + RENAME: { + serverId: string; + oldName: string; + newName: string; + reason: string; + user: string; + }; + SETNAME: { serverId: string; user: string; realname: string }; + INVITE: EventWithTags & { + inviter: string; + target: string; + channel: string; + }; + FAIL: EventWithTags & { + command: string; + code: string; + target?: string; + message: string; + }; + WARN: EventWithTags & { + command: string; + code: string; + target?: string; + message: string; + }; + NOTE: EventWithTags & { + command: string; + code: string; + target?: string; + message: string; + }; + SUCCESS: EventWithTags & { + command: string; + code: string; + target?: string; + message: string; + }; + REGISTER_SUCCESS: EventWithTags & { + account: string; + message: string; + }; + REGISTER_VERIFICATION_REQUIRED: EventWithTags & { + account: string; + message: string; + }; + VERIFY_SUCCESS: EventWithTags & { + account: string; + message: string; + }; + WHO_REPLY: { + serverId: string; + channel: string; + username: string; + host: string; + server: string; + nick: string; + flags: string; + hopcount: string; + realname: string; + }; + WHOX_REPLY: { + serverId: string; + channel: string; + username: string; + host: string; + nick: string; + account: string; + flags: string; + realname: string; + isAway: boolean; + opLevel: string; + }; + WHO_END: { serverId: string; mask: string }; + RPL_AWAY: { + serverId: string; + nick: string; + awayMessage: string; + }; + RPL_YOUREOPER: BaseIRCEvent & { + message: string; + }; + RPL_YOURHOST: BaseIRCEvent & { + serverName: string; + version: string; + }; + MONONLINE: BaseIRCEvent & { + targets: Array<{ nick: string; user?: string; host?: string }>; + }; + MONOFFLINE: BaseIRCEvent & { + targets: string[]; + }; + MONLIST: BaseIRCEvent & { + targets: string[]; + }; + ENDOFMONLIST: BaseIRCEvent; + MONLISTFULL: BaseIRCEvent & { + limit: number; + targets: string[]; + }; + EXTJWT: BaseIRCEvent & { + requestedTarget: string; + serviceName: string; + jwtToken: string; + }; + WHOIS_BOT: { + serverId: string; + nick: string; + target: string; + message: string; + }; + AWAY: { + serverId: string; + username: string; + awayMessage?: string; + }; + CHGHOST: { + serverId: string; + username: string; + newUser: string; + newHost: string; + }; + RPL_NOWAWAY: { + serverId: string; + message: string; + }; + RPL_UNAWAY: { + serverId: string; + message: string; + }; + NICK_ERROR: { + serverId: string; + code: string; + error: string; + nick?: string; + message: string; + }; + CHATHISTORY_LOADING: { + serverId: string; + channelName: string; + isLoading: boolean; + }; + RPL_BANLIST: { + serverId: string; + channel: string; + mask: string; + setter: string; + timestamp: number; + }; + RPL_INVITELIST: { + serverId: string; + channel: string; + mask: string; + setter: string; + timestamp: number; + }; + RPL_EXCEPTLIST: { + serverId: string; + channel: string; + mask: string; + setter: string; + timestamp: number; + }; + RPL_ENDOFBANLIST: { + serverId: string; + channel: string; + }; + RPL_ENDOFINVITELIST: { + serverId: string; + channel: string; + }; + RPL_ENDOFEXCEPTLIST: { + serverId: string; + channel: string; + }; + TOPIC: { + serverId: string; + channelName: string; + topic: string; + sender: string; + }; + RPL_TOPIC: { + serverId: string; + channelName: string; + topic: string; + }; + RPL_TOPICWHOTIME: { + serverId: string; + channelName: string; + setter: string; + timestamp: number; + }; + RPL_NOTOPIC: { + serverId: string; + channelName: string; + }; + WHOIS_USER: { + serverId: string; + nick: string; + username: string; + host: string; + realname: string; + }; + WHOIS_SERVER: { + serverId: string; + nick: string; + server: string; + serverInfo: string; + }; + WHOIS_IDLE: { + serverId: string; + nick: string; + idle: number; + signon: number; + }; + WHOIS_CHANNELS: { + serverId: string; + nick: string; + channels: string; + }; + WHOIS_SPECIAL: { + serverId: string; + nick: string; + message: string; + }; + WHOIS_ACCOUNT: { + serverId: string; + nick: string; + account: string; + }; + WHOIS_SECURE: { + serverId: string; + nick: string; + message: string; + }; + WHOIS_END: { + serverId: string; + nick: string; + }; +} + +export type EventKey = keyof EventMap; +export type EventCallback = (data: EventMap[K]) => void; + +export type { + BaseIRCEvent, + BaseMessageEvent, + BaseMetadataEvent, + BaseUserActionEvent, + Channel, + ConnectionState, + EventWithTags, + MetadataValueEvent, + Server, + User, +}; diff --git a/src/lib/irc/utils/ircUtils.ts b/src/lib/irc/utils/ircUtils.ts new file mode 100644 index 00000000..132ff53c --- /dev/null +++ b/src/lib/irc/utils/ircUtils.ts @@ -0,0 +1,85 @@ +import type { User } from "../../../types"; + +/** + * Extract nickname from nick!user@host format + */ +export function getNickFromNuh(nuh: string): string { + const nick = nuh.split("!")[0]; + return nick.startsWith(":") ? nick.substring(1) : nick; +} + +/** + * Get timestamp from IRC message tags, with fallback to current time + */ +export function getTimestampFromTags( + mtags: Record | undefined, +): Date { + if (mtags?.time) { + return new Date(mtags.time); + } + return new Date(); +} + +/** + * Parse IRC message tags (@key=value;key2=value2) + */ +export function parseMessageTags(tags: string): Record { + const parsedTags: Record = {}; + const tagPairs = tags.substring(1).split(";"); + + for (const tag of tagPairs) { + const [key, value] = tag.split("="); + parsedTags[key] = value?.trim() ?? ""; // empty string fallback + } + + return parsedTags; +} + +/** + * Parse NAMES response (353 numeric) into User objects + */ +export function parseNamesResponse(namesResponse: string): User[] { + const users: User[] = []; + for (const name of namesResponse.split(" ")) { + // Try to match with userhost format first (nick!user@host) + let regex = /([~&@%+]*)([^\s!]+)!/; + let match = regex.exec(name); + + if (!match) { + // If no match, try without ! (just nickname) + regex = /([~&@%+]*)([^\s!]+)/; + match = regex.exec(name); + } + + if (match) { + const [_, prefix, username] = match; + users.push({ + id: username, + username, + status: prefix, + isOnline: true, + }); + } + } + return users; +} + +/** + * Parse ISUPPORT tokens (005 numeric) + */ +export function parseIsupport(tokens: string): Record { + const tokenMap: Record = {}; + const tokenPairs = tokens.split(" "); + + for (const token of tokenPairs) { + const [key, value] = token.split("="); + if (value) { + // Replace \x20 with actual space character + tokenMap[key] = value.replace(/\\x20/g, " "); + } else { + tokenMap[key] = ""; // empty string fallback + } + } + + return tokenMap; +} diff --git a/src/lib/ircClient.ts b/src/lib/ircClient.ts index 1d71d569..074c3749 100644 --- a/src/lib/ircClient.ts +++ b/src/lib/ircClient.ts @@ -1,2746 +1,31 @@ -import { v4 as uuidv4 } from "uuid"; -import type { +// Re-export the main IRCClient class +export { IRCClient } from "./irc/index"; + +// Create and export a singleton instance for backward compatibility +import { IRCClient } from "./irc/index"; + +export const ircClient = new IRCClient(); + +export default ircClient; + +// Re-export types from the types module +export type { BaseIRCEvent, BaseMessageEvent, BaseMetadataEvent, BaseUserActionEvent, - Channel, - ConnectionState, + EventCallback, + EventKey, + EventMap, EventWithTags, MetadataValueEvent, - Server, - User, -} from "../types"; -import { +} from "./irc/types"; + +// Re-export utility functions +export { + getNickFromNuh, + getTimestampFromTags, parseIsupport, parseMessageTags, parseNamesResponse, -} from "./ircUtils"; - -export interface EventMap { - ready: BaseIRCEvent & { serverName: string; nickname: string }; - connectionStateChange: BaseIRCEvent & { - serverId: string; - connectionState: ConnectionState; - }; - NICK: EventWithTags & { - oldNick: string; - newNick: string; - }; - QUIT: BaseUserActionEvent & { reason: string; batchTag?: string }; - JOIN: BaseUserActionEvent & { - channelName: string; - batchTag?: string; - account?: string; // From extended-join - realname?: string; // From extended-join - }; - PART: BaseUserActionEvent & { - channelName: string; - reason?: string; - }; - KICK: EventWithTags & { - username: string; - channelName: string; - target: string; - reason: string; - }; - MODE: EventWithTags & { - sender: string; - target: string; - modestring: string; - modeargs: string[]; - }; - RPL_CHANNELMODEIS: BaseIRCEvent & { - channelName: string; - modestring: string; - modeargs: string[]; - }; - CHANMSG: BaseMessageEvent & { - channelName: string; - }; - USERMSG: BaseMessageEvent & { - target: string; // The recipient of the PRIVMSG (for whispers) - }; - CHANNNOTICE: BaseMessageEvent & { - channelName: string; - }; - USERNOTICE: BaseMessageEvent; - TAGMSG: EventWithTags & { - sender: string; - channelName: string; - timestamp: Date; - }; - REDACT: EventWithTags & { - target: string; - msgid: string; - reason: string; - sender: string; - }; - NAMES: BaseIRCEvent & { channelName: string; users: User[] }; - "CAP LS": BaseIRCEvent & { cliCaps: string }; - "CAP ACK": BaseIRCEvent & { cliCaps: string }; - ISUPPORT: BaseIRCEvent & { key: string; value: string }; - CAP_ACKNOWLEDGED: BaseIRCEvent & { key: string; capabilities: string }; - CAP_END: BaseIRCEvent; - AUTHENTICATE: BaseIRCEvent & { param: string }; - METADATA: MetadataValueEvent; - METADATA_WHOIS: MetadataValueEvent; - METADATA_KEYVALUE: MetadataValueEvent; - METADATA_KEYNOTSET: BaseMetadataEvent; - METADATA_SUBOK: BaseIRCEvent & { keys: string[] }; - METADATA_UNSUBOK: BaseIRCEvent & { keys: string[] }; - METADATA_SUBS: BaseIRCEvent & { keys: string[] }; - METADATA_SYNCLATER: BaseIRCEvent & { target: string; retryAfter?: number }; - BATCH_START: BaseIRCEvent & { - batchId: string; - type: string; - parameters?: string[]; - }; - BATCH_END: BaseIRCEvent & { batchId: string }; - MULTILINE_MESSAGE: BaseMessageEvent & { - channelName?: string; - lines: string[]; - messageIds: string[]; // All message IDs that make up this multiline message - }; - METADATA_FAIL: BaseIRCEvent & { - subcommand: string; - code: string; - target?: string; - key?: string; - retryAfter?: number; - }; - LIST_CHANNEL: { - serverId: string; - channel: string; - userCount: number; - topic: string; - }; - LIST_END: { serverId: string }; - RENAME: { - serverId: string; - oldName: string; - newName: string; - reason: string; - user: string; - }; - SETNAME: { serverId: string; user: string; realname: string }; - INVITE: EventWithTags & { - inviter: string; - target: string; - channel: string; - }; - FAIL: EventWithTags & { - command: string; - code: string; - target?: string; - message: string; - }; - WARN: EventWithTags & { - command: string; - code: string; - target?: string; - message: string; - }; - NOTE: EventWithTags & { - command: string; - code: string; - target?: string; - message: string; - }; - SUCCESS: EventWithTags & { - command: string; - code: string; - target?: string; - message: string; - }; - REGISTER_SUCCESS: EventWithTags & { - account: string; - message: string; - }; - REGISTER_VERIFICATION_REQUIRED: EventWithTags & { - account: string; - message: string; - }; - VERIFY_SUCCESS: EventWithTags & { - account: string; - message: string; - }; - WHO_REPLY: { - serverId: string; - channel: string; - username: string; - host: string; - server: string; - nick: string; - flags: string; - hopcount: string; - realname: string; - }; - WHOX_REPLY: { - serverId: string; - channel: string; - username: string; - host: string; - nick: string; - account: string; - flags: string; - realname: string; - isAway: boolean; - opLevel: string; - }; - WHO_END: { serverId: string; mask: string }; - RPL_AWAY: { - serverId: string; - nick: string; - awayMessage: string; - }; - RPL_YOUREOPER: BaseIRCEvent & { - message: string; - }; - RPL_YOURHOST: BaseIRCEvent & { - serverName: string; - version: string; - }; - MONONLINE: BaseIRCEvent & { - targets: Array<{ nick: string; user?: string; host?: string }>; - }; - MONOFFLINE: BaseIRCEvent & { - targets: string[]; // Just nicknames - }; - MONLIST: BaseIRCEvent & { - targets: string[]; - }; - ENDOFMONLIST: BaseIRCEvent; - MONLISTFULL: BaseIRCEvent & { - limit: number; - targets: string[]; - }; - EXTJWT: BaseIRCEvent & { - requestedTarget: string; - serviceName: string; - jwtToken: string; - }; - WHOIS_BOT: { - serverId: string; - nick: string; - target: string; - message: string; - }; - AWAY: { - serverId: string; - username: string; - awayMessage?: string; - }; - CHGHOST: { - serverId: string; - username: string; - newUser: string; - newHost: string; - }; - RPL_NOWAWAY: { - serverId: string; - message: string; - }; - RPL_UNAWAY: { - serverId: string; - message: string; - }; - NICK_ERROR: { - serverId: string; - code: string; - error: string; - nick?: string; - message: string; - }; - CHATHISTORY_LOADING: { - serverId: string; - channelName: string; - isLoading: boolean; - }; - RPL_BANLIST: { - serverId: string; - channel: string; - mask: string; - setter: string; - timestamp: number; - }; - RPL_INVITELIST: { - serverId: string; - channel: string; - mask: string; - setter: string; - timestamp: number; - }; - RPL_EXCEPTLIST: { - serverId: string; - channel: string; - mask: string; - setter: string; - timestamp: number; - }; - RPL_ENDOFBANLIST: { - serverId: string; - channel: string; - }; - RPL_ENDOFINVITELIST: { - serverId: string; - channel: string; - }; - RPL_ENDOFEXCEPTLIST: { - serverId: string; - channel: string; - }; - TOPIC: { - serverId: string; - channelName: string; - topic: string; - sender: string; - }; - RPL_TOPIC: { - serverId: string; - channelName: string; - topic: string; - }; - RPL_TOPICWHOTIME: { - serverId: string; - channelName: string; - setter: string; - timestamp: number; - }; - RPL_NOTOPIC: { - serverId: string; - channelName: string; - }; - WHOIS_USER: { - serverId: string; - nick: string; - username: string; - host: string; - realname: string; - }; - WHOIS_SERVER: { - serverId: string; - nick: string; - server: string; - serverInfo: string; - }; - WHOIS_IDLE: { - serverId: string; - nick: string; - idle: number; - signon: number; - }; - WHOIS_CHANNELS: { - serverId: string; - nick: string; - channels: string; - }; - WHOIS_SPECIAL: { - serverId: string; - nick: string; - message: string; - }; - WHOIS_ACCOUNT: { - serverId: string; - nick: string; - account: string; - }; - WHOIS_SECURE: { - serverId: string; - nick: string; - message: string; - }; - WHOIS_END: { - serverId: string; - nick: string; - }; -} - -type EventKey = keyof EventMap; -type EventCallback = (data: EventMap[K]) => void; - -export class IRCClient { - private sockets: Map = new Map(); - private servers: Map = new Map(); - private nicks: Map = new Map(); - private currentUsers: Map = new Map(); // Per-server current users - private saslMechanisms: Map = new Map(); - private capLsAccumulated: Map> = new Map(); - private saslEnabled: Map = new Map(); - private saslCredentials: Map = - new Map(); - private pendingConnections: Map> = new Map(); - private pendingCapReqs: Map = new Map(); // Track how many CAP REQ batches are pending ACK - private capNegotiationComplete: Map = new Map(); // Track if CAP negotiation is complete - private reconnectionAttempts: Map = new Map(); // Track reconnection attempts per server - private reconnectionTimeouts: Map = new Map(); // Track reconnection timeouts per server - private pingTimers: Map = new Map(); // Track ping timers per server - private pongTimeouts: Map = new Map(); // Track pong timeouts per server - private activeBatches: Map< - string, - Map< - string, - { - type: string; - parameters?: string[]; - messages: string[]; - concatFlags?: boolean[]; - sender?: string; - messageIds?: string[]; - timestamps?: Date[]; - batchMsgId?: string; - batchTime?: Date; - } - > - > = new Map(); // Track active batches per server - - private ourCaps: string[] = [ - "multi-prefix", - "message-tags", - "server-time", - "echo-message", - "userhost-in-names", - "draft/chathistory", - "draft/extended-isupport", - "sasl", - "cap-notify", - "draft/channel-rename", - "setname", - "account-notify", - "account-tag", - "extended-join", - "away-notify", - "chghost", - "draft/metadata-2", - "draft/message-redaction", - "draft/account-registration", - "batch", - "draft/multiline", - "draft/typing", - "draft/channel-context", - "znc.in/playback", - "unrealircd.org/json-log", - "invite-notify", - "monitor", - "extended-monitor", - // Note: unrealircd.org/link-security is informational only, don't request it - ]; - - private eventCallbacks: { - [K in EventKey]?: EventCallback[]; - } = {}; - - public version = __APP_VERSION__; - - connect( - name: string, - host: string, - port: number, - nickname: string, - password?: string, - _saslAccountName?: string, - _saslPassword?: string, - serverId?: string, - ): Promise { - const connectionKey = `${host}:${port}`; - - // Check if there's already a pending connection to this server - const existingConnection = this.pendingConnections.get(connectionKey); - if (existingConnection) { - return existingConnection; - } - - // Check if already connected to this server (but allow reconnection if serverId is provided) - if (!serverId) { - const existingServer = Array.from(this.servers.values()).find( - (server) => server.host === host && server.port === port, - ); - if (existingServer) { - return Promise.resolve(existingServer); - } - } - - // Create a new connection promise and store it - const connectionPromise = new Promise((resolve, reject) => { - // for local testing and automated tests, if domain is localhost or 127.0.0.1 use ws instead of wss - const protocol = ["localhost", "127.0.0.1"].includes(host) ? "ws" : "wss"; - const url = `${protocol}://${host}:${port}`; - - let socket: WebSocket; - try { - socket = new WebSocket(url); - } catch (error) { - reject(new Error(`Failed to connect to ${host}:${port}`)); - return; - } - - // Create server object immediately and add to servers map - // Use provided name, default to host if name is empty - const finalName = name?.trim() || host; - - // Check if we're reconnecting to an existing server - let server: Server; - if (serverId && this.servers.has(serverId)) { - // Reuse existing server object for reconnection - const existingServer = this.servers.get(serverId); - if (existingServer) { - server = existingServer; - // Update connection state - server.connectionState = "connecting"; - server.isConnected = false; - // Reset CAP negotiation state for reconnection - this.capNegotiationComplete.delete(serverId); - } else { - throw new Error(`Server ${serverId} not found despite has() check`); - } - } else { - // Create new server object - server = { - id: serverId || uuidv4(), - name: finalName, - host, - port, - channels: [], - privateChats: [], - isConnected: false, // Not connected yet - connectionState: "connecting", - users: [], - }; - this.servers.set(server.id, server); - } - this.sockets.set(server.id, socket); - // Only enable SASL if we have both account name AND password - this.saslEnabled.set(server.id, !!(_saslAccountName && _saslPassword)); - - // Store SASL credentials if provided - if (_saslAccountName && _saslPassword) { - this.saslCredentials.set(server.id, { - username: _saslAccountName, - password: _saslPassword, - }); - } else { - } - - this.currentUsers.set(server.id, { - id: uuidv4(), - username: nickname, - isOnline: true, - status: "online", - }); - this.nicks.set(server.id, nickname); - - socket.onopen = () => { - //registerAllProtocolHandlers(this); - // Send IRC commands to register the user - if (password) { - socket.send(`PASS ${password}`); - } - - socket.send("CAP LS 302"); - socket.send(`NICK ${nickname}`); - - // Update server to mark as connected - server.isConnected = true; - server.connectionState = "connected"; - this.triggerEvent("connectionStateChange", { - serverId: server.id, - connectionState: "connected", - }); - - // Rejoin channels if this is a reconnection - if (server.channels.length > 0) { - for (const channel of server.channels) { - if (serverId) { - this.sendRaw(serverId, `JOIN ${channel.name}`); - } - } - } - - // Start WebSocket ping timer for keepalive - this.startWebSocketPing(server.id); - - socket.onclose = () => { - console.log(`WebSocket onclose for server ${serverId}`); - // Stop WebSocket ping timers - this.stopWebSocketPing(server.id); - this.sockets.delete(server.id); - server.isConnected = false; - // Only start reconnection if not already reconnecting (e.g., from ERROR handler) - const wasReconnecting = server.connectionState === "reconnecting"; - server.connectionState = "disconnected"; - this.triggerEvent("connectionStateChange", { - serverId: server.id, - connectionState: "disconnected", - }); - this.pendingConnections.delete(connectionKey); - // Start reconnection logic only if not already reconnecting - if (!wasReconnecting) { - this.startReconnection( - server.id, - name, - host, - port, - nickname, - password, - _saslAccountName, - _saslPassword, - ); - } - }; - - resolve(server); - }; - - socket.onerror = (error) => { - // Mark server as disconnected but keep it in the map - server.isConnected = false; - server.connectionState = "disconnected"; - this.sockets.delete(server.id); - this.pendingConnections.delete(connectionKey); - reject(new Error(`Failed to connect to ${host}:${port}`)); - }; - - socket.onmessage = (event) => { - const serverId = Array.from(this.servers.keys()).find( - (id) => this.sockets.get(id) === socket, - ); - if (serverId) { - this.handleMessage(event.data, serverId); - } - }; - }); - - // Store the pending connection - this.pendingConnections.set(connectionKey, connectionPromise); - - // Clean up the pending connection when it resolves or rejects - connectionPromise.finally(() => { - this.pendingConnections.delete(connectionKey); - }); - - return connectionPromise; - } - - disconnect(serverId: string): void { - const socket = this.sockets.get(serverId); - if (socket) { - socket.send("QUIT :ObsidianIRC - Bringing IRC into the future"); - socket.close(); - this.sockets.delete(serverId); - } - const server = this.servers.get(serverId); - if (server) { - server.isConnected = false; - server.connectionState = "disconnected"; - this.triggerEvent("connectionStateChange", { - serverId: server.id, - connectionState: "disconnected", - }); - const connectionKey = `${server.host}:${server.port}`; - this.pendingConnections.delete(connectionKey); - } - // Clear reconnection state - this.reconnectionAttempts.delete(serverId); - const timeout = this.reconnectionTimeouts.get(serverId); - if (timeout) { - clearTimeout(timeout); - this.reconnectionTimeouts.delete(serverId); - } - // Stop WebSocket ping timers - this.stopWebSocketPing(serverId); - } - - private startReconnection( - serverId: string, - name: string, - host: string, - port: number, - nickname: string, - password?: string, - saslAccountName?: string, - saslPassword?: string, - ): void { - console.log(`Starting reconnection for server ${serverId}`); - const server = this.servers.get(serverId); - if (!server) return; - - // Cancel any existing reconnection - const existingTimeout = this.reconnectionTimeouts.get(serverId); - if (existingTimeout) { - clearTimeout(existingTimeout); - } - - const attempts = this.reconnectionAttempts.get(serverId) || 0; - this.reconnectionAttempts.set(serverId, attempts + 1); - - // Calculate delay based on attempt count - let delay = 0; - if (attempts === 0) { - delay = 0; // Immediate retry for testing - } else if (attempts === 1) { - delay = 15000; // 15 seconds - } else if (attempts === 2) { - delay = 30000; // 30 seconds - } else if (attempts <= 100) { - delay = 60000; // 60 seconds for attempts 3-100 - } else { - // After 100 attempts, stop trying - server.connectionState = "disconnected"; - this.triggerEvent("connectionStateChange", { - serverId: server.id, - connectionState: "disconnected", - }); - return; - } - - server.connectionState = "reconnecting"; - this.triggerEvent("connectionStateChange", { - serverId: server.id, - connectionState: "reconnecting", - }); - - this.reconnectionTimeouts.set( - serverId, - setTimeout(() => { - console.log( - `Reconnection timeout fired for server ${serverId}, attempting reconnection`, - ); - this.attemptReconnection( - serverId, - name, - host, - port, - nickname, - password, - saslAccountName, - saslPassword, - ); - }, delay), - ); - } - - private async attemptReconnection( - serverId: string, - name: string, - host: string, - port: number, - nickname: string, - password?: string, - saslAccountName?: string, - saslPassword?: string, - ): Promise { - console.log(`Attempting reconnection for server ${serverId}`); - const server = this.servers.get(serverId); - if (!server) return; - - try { - server.connectionState = "connecting"; - this.triggerEvent("connectionStateChange", { - serverId: server.id, - connectionState: "connecting", - }); - await this.connect( - name, - host, - port, - nickname, - password, - saslAccountName, - saslPassword, - serverId, - ); - console.log(`Reconnection successful for server ${serverId}`); - // Success - reset reconnection attempts - this.reconnectionAttempts.delete(serverId); - this.reconnectionTimeouts.delete(serverId); - } catch (error) { - console.log(`Reconnection failed for server ${serverId}:`, error); - // Failed - try again - this.startReconnection( - serverId, - name, - host, - port, - nickname, - password, - saslAccountName, - saslPassword, - ); - } - } - - sendRaw(serverId: string, command: string): void { - const socket = this.sockets.get(serverId); - if (socket && socket.readyState === WebSocket.OPEN) { - console.log( - `IRC Client: Sending command to server ${serverId}: ${command}`, - ); - // Log metadata and command-related outgoing messages for debugging - if (command.startsWith("METADATA") || command.startsWith("/")) { - } - socket.send(command); - } else { - console.error(`Socket for server ${serverId} is not open`); - } - } - - private startWebSocketPing(serverId: string): void { - // Clear any existing ping timer - this.stopWebSocketPing(serverId); - - // Send ping every 30 seconds - const pingTimer = setInterval(() => { - const socket = this.sockets.get(serverId); - if (socket && socket.readyState === WebSocket.OPEN) { - try { - // Send WebSocket ping frame (opcode 0x9) - // Since we can't send ping frames directly in JS, we'll send an IRC PING - // which serves as both IRC keepalive and WebSocket activity - const timestamp = Date.now().toString(); - this.sendRaw(serverId, `PING ${timestamp}`); - - // Set a timeout for pong response (10 seconds) - const pongTimeout = setTimeout(() => { - console.warn( - `WebSocket ping timeout for server ${serverId}, closing connection`, - ); - socket.close(1000, "Ping timeout"); - }, 10000); - - this.pongTimeouts.set(serverId, pongTimeout); - } catch (error) { - console.error(`Failed to send ping for server ${serverId}:`, error); - } - } - }, 30000); // 30 seconds - - this.pingTimers.set(serverId, pingTimer); - } - - private stopWebSocketPing(serverId: string): void { - const pingTimer = this.pingTimers.get(serverId); - if (pingTimer) { - clearInterval(pingTimer); - this.pingTimers.delete(serverId); - } - - const pongTimeout = this.pongTimeouts.get(serverId); - if (pongTimeout) { - clearTimeout(pongTimeout); - this.pongTimeouts.delete(serverId); - } - } - - joinChannel(serverId: string, channelName: string): Channel { - const server = this.servers.get(serverId); - if (server) { - const existing = server.channels.find((c) => c.name === channelName); - if (existing) return existing; - - this.sendRaw(serverId, `JOIN ${channelName}`); - - // Only request CHATHISTORY if the server supports it - if (server.capabilities?.includes("draft/chathistory")) { - this.sendRaw(serverId, `CHATHISTORY LATEST ${channelName} * 50`); - } - - const channel: Channel = { - id: uuidv4(), - name: channelName, - topic: "", - isPrivate: false, - serverId, - unreadCount: 0, - isMentioned: false, - messages: [], - users: [], - isLoadingHistory: !!server.capabilities?.includes("draft/chathistory"), // Only loading if we requested history - needsWhoRequest: true, // Need to request WHO after CHATHISTORY completes (or immediately if no CHATHISTORY) - chathistoryRequested: - !!server.capabilities?.includes("draft/chathistory"), // Mark that we've requested CHATHISTORY only if supported - }; - server.channels.push(channel); - - // Trigger event to notify store that history loading started (only if we actually requested it) - if (server.capabilities?.includes("draft/chathistory")) { - this.triggerEvent("CHATHISTORY_LOADING", { - serverId, - channelName, - isLoading: true, - }); - } - - return channel; - } - throw new Error(`Server with ID ${serverId} not found`); - } - - leaveChannel(serverId: string, channelName: string): void { - const server = this.servers.get(serverId); - if (server) { - this.sendRaw(serverId, `PART ${channelName}`); - server.channels = server.channels.filter((c) => c.name !== channelName); - } - } - - sendMessage(serverId: string, channelId: string, content: string): void { - const server = this.servers.get(serverId); - if (!server) throw new Error(`Server ${serverId} not found`); - const channel = server.channels.find((c) => c.id === channelId); - if (!channel) throw new Error(`Channel ${channelId} not found`); - - // Check if server supports multiline and message has newlines - // Note: We'll check server capabilities from the store later via helper function - const lines = content.split("\n"); - - if (lines.length > 1) { - // For now, send multiline if there are multiple lines - // Server capability check will be done by the calling code - this.sendMultilineMessage(serverId, channel.name, lines); - } else { - // Send as regular single message - this.sendRaw(serverId, `PRIVMSG ${channel.name} :${content}`); - } - } - - sendWhisper( - serverId: string, - targetUser: string, - channelName: string, - content: string, - ): void { - // Send a whisper with the draft/channel-context tag - // Format: @+draft/channel-context=#channel PRIVMSG targetUser :message - this.sendRaw( - serverId, - `@+draft/channel-context=${channelName} PRIVMSG ${targetUser} :${content}`, - ); - } - - setTopic(serverId: string, channelName: string, topic: string): void { - // Send TOPIC command to set the channel topic - // Format: TOPIC #channel :New topic text - this.sendRaw(serverId, `TOPIC ${channelName} :${topic}`); - } - - getTopic(serverId: string, channelName: string): void { - // Send TOPIC command without parameters to get current topic - // Format: TOPIC #channel - this.sendRaw(serverId, `TOPIC ${channelName}`); - } - - whois(serverId: string, nickname: string): void { - // Send WHOIS command to get user information - // Format: WHOIS nickname - this.sendRaw(serverId, `WHOIS ${nickname}`); - } - - sendMultilineMessage( - serverId: string, - target: string, - lines: string[], - ): void { - const batchId = `ml_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - - // Start multiline batch - this.sendRaw(serverId, `BATCH +${batchId} draft/multiline ${target}`); - - // Send each line as a separate PRIVMSG with batch tag - // Handle long lines by splitting them if needed - for (const line of lines) { - const splitLines = this.splitLongLine(line); - for (const splitLine of splitLines) { - this.sendRaw( - serverId, - `@batch=${batchId} PRIVMSG ${target} :${splitLine}`, - ); - } - } - - // End batch - this.sendRaw(serverId, `BATCH -${batchId}`); - } - - // Split long lines to respect IRC message length limits (512 bytes) - private splitLongLine(text: string, maxLength = 450): string[] { - if (!text) return [""]; - - // Account for IRC overhead (PRIVMSG + target + formatting) - // Conservative limit to account for formatting codes and IRC overhead - const lines: string[] = []; - let remaining = text; - - while (remaining.length > maxLength) { - // Try to split at word boundaries - let splitIndex = maxLength; - const lastSpace = remaining.lastIndexOf(" ", maxLength); - if (lastSpace > maxLength * 0.7) { - // Don't split too early - splitIndex = lastSpace; - } - - lines.push(remaining.substring(0, splitIndex)); - remaining = remaining.substring(splitIndex).trim(); - } - - if (remaining) { - lines.push(remaining); - } - - return lines.length > 0 ? lines : [""]; - } - - sendTyping(serverId: string, target: string, isActive: boolean): void { - const typingState = isActive ? "active" : "done"; - this.sendRaw(serverId, `@+typing=${typingState} TAGMSG ${target}`); - } - - sendRedact( - serverId: string, - target: string, - msgid: string, - reason?: string, - ): void { - const command = reason - ? `REDACT ${target} ${msgid} :${reason}` - : `REDACT ${target} ${msgid}`; - this.sendRaw(serverId, command); - } - - registerAccount( - serverId: string, - account: string, - email: string, - password: string, - ): void { - this.sendRaw(serverId, `REGISTER ${account} ${email} ${password}`); - } - - verifyAccount(serverId: string, account: string, code: string): void { - this.sendRaw(serverId, `VERIFY ${account} ${code}`); - } - - listChannels( - serverId: string, - elist?: string, - filters?: { - minUsers?: number; - maxUsers?: number; - minCreationTime?: number; // minutes ago - maxCreationTime?: number; // minutes ago - minTopicTime?: number; // minutes ago - maxTopicTime?: number; // minutes ago - mask?: string; - notMask?: string; - }, - ): void { - let command = "LIST"; - - if (elist && filters) { - // Build LIST parameters based on filters and available ELIST capabilities - const elistTokens = elist.toUpperCase().split(""); - const params: string[] = []; - - // User count filtering (U extension) - if (elistTokens.includes("U")) { - if (filters.minUsers && filters.minUsers > 0) { - params.push(`>${filters.minUsers}`); - } - if (filters.maxUsers && filters.maxUsers > 0) { - params.push(`<${filters.maxUsers}`); - } - } - - // Creation time filtering (C extension) - if (elistTokens.includes("C")) { - if (filters.minCreationTime && filters.minCreationTime > 0) { - params.push(`C>${filters.minCreationTime}`); - } - if (filters.maxCreationTime && filters.maxCreationTime > 0) { - params.push(`C<${filters.maxCreationTime}`); - } - } - - // Topic time filtering (T extension) - if (elistTokens.includes("T")) { - if (filters.minTopicTime && filters.minTopicTime > 0) { - params.push(`T>${filters.minTopicTime}`); - } - if (filters.maxTopicTime && filters.maxTopicTime > 0) { - params.push(`T<${filters.maxTopicTime}`); - } - } - - // Mask filtering (M extension) - if (elistTokens.includes("M") && filters.mask) { - params.push(filters.mask); - } - - // Non-matching mask filtering (N extension) - if (elistTokens.includes("N") && filters.notMask) { - params.push(`!${filters.notMask}`); - } - - if (params.length > 0) { - command = `LIST ${params.join(" ")}`; - } - } - - this.sendRaw(serverId, command); - } - - renameChannel( - serverId: string, - oldName: string, - newName: string, - reason?: string, - ): void { - const command = reason - ? `RENAME ${oldName} ${newName} :${reason}` - : `RENAME ${oldName} ${newName}`; - this.sendRaw(serverId, command); - } - - setName(serverId: string, realname: string): void { - this.sendRaw(serverId, `SETNAME :${realname}`); - } - - changeNick(serverId: string, newNick: string): void { - this.sendRaw(serverId, `NICK ${newNick}`); - } - - // Metadata commands - metadataGet(serverId: string, target: string, keys: string[]): void { - const keysStr = keys.join(" "); - this.sendRaw(serverId, `METADATA ${target} GET ${keysStr}`); - } - - metadataList(serverId: string, target: string): void { - this.sendRaw(serverId, `METADATA ${target} LIST`); - } - - metadataSet( - serverId: string, - target: string, - key: string, - value?: string, - visibility?: string, - ): void { - // Use the provided target. If it's "*" or the current user's nickname, use "*" - // Otherwise use the provided target (for channels, other users if admin, etc.) - const currentNick = this.getNick(serverId); - const actualTarget = - target === "*" || target === currentNick ? "*" : target; - const command = - value !== undefined && value !== "" - ? `METADATA ${actualTarget} SET ${key} :${value}` - : `METADATA ${actualTarget} SET ${key}`; - this.sendRaw(serverId, command); - } - - metadataClear(serverId: string, target: string): void { - this.sendRaw(serverId, `METADATA ${target} CLEAR`); - } - - metadataSub(serverId: string, keys: string[]): void { - // Send individual SUB commands for each key to avoid parsing issues - keys.forEach((key) => { - const command = `METADATA * SUB ${key}`; - this.sendRaw(serverId, command); - }); - } - - metadataUnsub(serverId: string, keys: string[]): void { - const keysStr = keys.join(" "); - this.sendRaw(serverId, `METADATA * UNSUB ${keysStr}`); - } - - metadataSubs(serverId: string): void { - this.sendRaw(serverId, "METADATA * SUBS"); - } - - metadataSync(serverId: string, target: string): void { - this.sendRaw(serverId, `METADATA ${target} SYNC`); - } - - // EXTJWT commands - requestExtJwt(serverId: string, target?: string, serviceName?: string): void { - // EXTJWT ( | * ) [service_name] - const targetParam = target || "*"; - const command = serviceName - ? `EXTJWT ${targetParam} ${serviceName}` - : `EXTJWT ${targetParam}`; - this.sendRaw(serverId, command); - } - - // MONITOR commands - monitorAdd(serverId: string, targets: string[]): void { - const targetsStr = targets.join(","); - this.sendRaw(serverId, `MONITOR + ${targetsStr}`); - } - - monitorRemove(serverId: string, targets: string[]): void { - const targetsStr = targets.join(","); - this.sendRaw(serverId, `MONITOR - ${targetsStr}`); - } - - monitorClear(serverId: string): void { - this.sendRaw(serverId, "MONITOR C"); - } - - monitorList(serverId: string): void { - this.sendRaw(serverId, "MONITOR L"); - } - - monitorStatus(serverId: string): void { - this.sendRaw(serverId, "MONITOR S"); - } - - markChannelAsRead(serverId: string, channelId: string): void { - const server = this.servers.get(serverId); - const channel = server?.channels.find((c) => c.id === channelId); - if (channel) channel.unreadCount = 0; - } - - capAck(serverId: string, key: string, capabilities: string): void { - this.triggerEvent("CAP_ACKNOWLEDGED", { serverId, key, capabilities }); - } - - capEnd(_serverId: string) {} - - isCapNegotiationComplete(serverId: string): boolean { - return this.capNegotiationComplete.get(serverId) ?? false; - } - - getNick(serverId: string): string | undefined { - return this.nicks.get(serverId); - } - - userOnConnect(serverId: string) { - const nickname = this.nicks.get(serverId); - if (!nickname) { - console.error(`No nickname found for serverId ${serverId}`); - return; - } - // NICK is already sent before CAP negotiation, only send USER now - this.sendRaw(serverId, `USER ${nickname} 0 * :${nickname}`); - } - - private handleMessage(data: string, serverId: string): void { - const lines = data.split("\r\n"); - for (let line of lines) { - let mtags: Record | undefined; - let source: string; - const parv: string[] = []; - let i = 0; - let l: string[]; - line = line.trim(); - - // Skip empty lines - if (!line) continue; - - console.log(`IRC Client: Received line from server ${serverId}: ${line}`); - - // Debug: Log ALL lines that contain CAP to see if CAP ACK is even being processed - if (line.includes("CAP")) { - } - - // Debug: Log all incoming IRC messages - - // Handle message tags first, before splitting on trailing parameter - let lineAfterTags = line; - if (line[0] === "@") { - const spaceIndex = line.indexOf(" "); - if (spaceIndex !== -1) { - mtags = parseMessageTags(line.substring(0, spaceIndex)); - lineAfterTags = line.substring(spaceIndex + 1); - } - } - - // Parse IRC message properly handling colon-prefixed trailing parameter - const spaceIndex = lineAfterTags.indexOf(" :"); - let trailing = ""; - let mainPart = lineAfterTags; - - if (spaceIndex !== -1) { - trailing = lineAfterTags.substring(spaceIndex + 2); // Skip ' :' - mainPart = lineAfterTags.substring(0, spaceIndex); - } - - l = mainPart.split(" ").filter((part) => part.length > 0); - - // Ensure we have at least one element - if (l.length === 0) continue; - - // Determine the source. if none, spoof as host server - if (l[i][0] !== ":") { - const thisServ = this.servers.get(serverId); - const thisServName = thisServ?.name; - if (!thisServName) { - // something has gone horribly wrong - return; - } - source = thisServName; - } else { - source = l[i].substring(1); - i++; - } - - const command = l[i]; - for (i++; l[i]; i++) { - parv.push(l[i]); - } - - // Add trailing parameter if it exists - if (trailing) { - parv.push(trailing); - } - - // Debug: ALWAYS log when line contains @time and CAP - if (line.includes("@time") && line.includes("CAP")) { - } - - // Debug: log command and parv for CAP messages - if (command === "CAP" || line.includes("CAP")) { - } - - // Debug: for message tags, show what l array looks like - if (line.includes("@time") && line.includes("CAP")) { - } - - const parc = parv.length; - - if (command === "PING") { - const key = parv.join(" "); - this.sendRaw(serverId, `PONG :${key}`); - } else if (command === "PONG") { - // Clear pong timeout since we received a response - const pongTimeout = this.pongTimeouts.get(serverId); - if (pongTimeout) { - clearTimeout(pongTimeout); - this.pongTimeouts.delete(serverId); - } - } else if (command === "ERROR") { - // Server is closing the connection - let onclose handler handle reconnection - const errorMessage = parv.join(" "); - console.log(`IRC ERROR from server ${serverId}: ${errorMessage}`); - - // Don't close the socket here, let the server close it and onclose handle reconnection - } else if (command === "001") { - const serverName = source; - const nickname = parv[0]; // Our actual nick as assigned by the server - - // Update our stored nick to match what the server assigned us - this.nicks.set(serverId, nickname); - - // Update current user's username to match server-assigned nick - const currentUser = this.currentUsers.get(serverId); - if (currentUser) { - this.currentUsers.set(serverId, { - ...currentUser, - username: nickname, - }); - } - - this.triggerEvent("ready", { serverId, serverName, nickname }); - - // Rejoin channels if this is a reconnection (server already has channels) - const server = this.servers.get(serverId); - if (server) { - console.log( - `Server ${serverId} has ${server.channels.length} channels:`, - server.channels.map((c) => c.name), - ); - if (server.channels.length > 0) { - console.log( - `Rejoining ${server.channels.length} channels after reconnection:`, - server.channels.map((c) => c.name), - ); - for (const channel of server.channels) { - console.log(`Sending JOIN for channel: ${channel.name}`); - this.sendRaw(serverId, `JOIN ${channel.name}`); - } - } else { - console.log(`No channels to rejoin for server ${serverId}`); - } - } else { - console.log(`Server ${serverId} not found for rejoining channels`); - } - } else if (command === "NICK") { - const oldNick = getNickFromNuh(source); - let newNick = parv[0]; - - // Remove leading colon if present - if (newNick.startsWith(":")) { - newNick = newNick.substring(1); - } - - // We changed our own nick - if (oldNick === this.nicks.get(serverId)) { - this.nicks.set(serverId, newNick); - // Update current user's username for this server - const currentUser = this.currentUsers.get(serverId); - if (currentUser) { - this.currentUsers.set(serverId, { - ...currentUser, - username: newNick, - }); - } - } - - this.triggerEvent("NICK", { - serverId, - mtags, - oldNick, - newNick, - }); - } else if (command === "QUIT") { - const username = getNickFromNuh(source); - const reason = parv.join(" "); - this.triggerEvent("QUIT", { - serverId, - username, - reason, - batchTag: mtags?.batch, - }); - } else if (command === "AWAY") { - // AWAY command for away-notify extension - // Format: :nick!user@host AWAY :away message - // or: :nick!user@host AWAY (when user returns) - const username = getNickFromNuh(source); - const awayMessage = parv.length > 0 ? parv.join(" ") : undefined; - this.triggerEvent("AWAY", { - serverId, - username, - awayMessage, - }); - } else if (command === "CHGHOST") { - // CHGHOST command for chghost extension - // Format: :nick!old_user@old_host CHGHOST new_user new_host - const username = getNickFromNuh(source); - const newUser = parv[0]; - const newHost = parv[1]; - this.triggerEvent("CHGHOST", { - serverId, - username, - newUser, - newHost, - }); - } else if (command === "JOIN") { - const username = getNickFromNuh(source); - const channelName = parv[0][0] === ":" ? parv[0].substring(1) : parv[0]; - - // Extended-join format: JOIN #channel account :realname - // Standard join format: JOIN #channel - let account: string | undefined; - let realname: string | undefined; - - if (parv.length >= 2) { - // Extended-join is enabled - account = parv[1] === "*" ? undefined : parv[1]; // * means no account - if (parv.length >= 3) { - realname = parv.slice(2).join(" "); - } - } - - this.triggerEvent("JOIN", { - serverId, - username, - channelName, - batchTag: mtags?.batch, - account, - realname, - }); - } else if (command === "PART") { - const username = getNickFromNuh(source); - const channelName = parv[0]; - parv[0] = ""; - const reason = parv.join(" ").trim(); - this.triggerEvent("PART", { - serverId, - username, - channelName, - reason, - }); - } else if (command === "KICK") { - const username = getNickFromNuh(source); - const channelName = parv[0]; - const target = parv[1]; - parv[0] = ""; - parv[1] = ""; - const reasonText = parv.join(" ").trim(); - const reason = reasonText.startsWith(":") - ? reasonText.substring(1) - : reasonText; - this.triggerEvent("KICK", { - serverId, - mtags, - username, - channelName, - target, - reason, - }); - } else if (command === "MODE") { - const sender = getNickFromNuh(source); - const target = parv[0]; - const modestring = parv[1] || ""; - const modeargs = parv.slice(2); - this.triggerEvent("MODE", { - serverId, - mtags, - sender, - target, - modestring, - modeargs, - }); - } else if (command === "INVITE") { - // Handle invite-notify capability - // Format: : INVITE - const inviter = getNickFromNuh(source); - const target = parv[0]; - const channel = parv[1]; - this.triggerEvent("INVITE", { - serverId, - mtags, - inviter, - target, - channel, - }); - } else if (command === "PRIVMSG") { - const target = parv[0]; - const isChannel = target.startsWith("#"); - const sender = getNickFromNuh(source); - - // Message content is in parv[1] and onwards after target - const message = parv.slice(1).join(" "); - - // Check if this message is part of a multiline batch - const batchId = mtags?.batch; - if (batchId) { - const serverBatches = this.activeBatches.get(serverId); - const batch = serverBatches?.get(batchId); - if ( - batch && - (batch.type === "multiline" || batch.type === "draft/multiline") - ) { - // Add this message line to the batch - batch.messages.push(message); - - // Store sender from the first message - if (!batch.sender) { - batch.sender = sender; - } - - // Track message IDs for redaction - if (mtags?.msgid && batch.messageIds) { - batch.messageIds.push(mtags.msgid); - } - - // Track timestamps for proper ordering - if (batch.timestamps) { - batch.timestamps.push(getTimestampFromTags(mtags)); - } - - // Track if this message has the concat flag - const hasMultilineConcat = - mtags && mtags["draft/multiline-concat"] !== undefined; - if (batch.concatFlags) { - batch.concatFlags.push(!!hasMultilineConcat); - } - - return; // Don't trigger individual message event, wait for batch completion - } - } - - if (isChannel) { - const channelName = target; - this.triggerEvent("CHANMSG", { - serverId, - mtags, - sender, - channelName, - message, - timestamp: getTimestampFromTags(mtags), - }); - } else { - this.triggerEvent("USERMSG", { - serverId, - mtags, - sender, - target, // The recipient of the PRIVMSG - message, - timestamp: getTimestampFromTags(mtags), - }); - } - } else if (command === "NOTICE") { - const target = parv[0]; - const isChannel = target.startsWith("#"); - const sender = getNickFromNuh(source); - - // The message content is now properly parsed as the trailing parameter - const message = trailing || parv.slice(1).join(" "); - - if (isChannel) { - const channelName = target; - this.triggerEvent("CHANNNOTICE", { - serverId, - mtags, - sender, - channelName, - message, - timestamp: getTimestampFromTags(mtags), - }); - } else { - this.triggerEvent("USERNOTICE", { - serverId, - mtags, - sender, - message, - timestamp: getTimestampFromTags(mtags), - }); - } - } else if (command === "TAGMSG") { - const rawTarget = parv[0] || ""; - const target = rawTarget.startsWith(":") - ? rawTarget.substring(1) - : rawTarget; - const sender = getNickFromNuh(source); - this.triggerEvent("TAGMSG", { - serverId, - mtags, - sender, - channelName: target, - timestamp: getTimestampFromTags(mtags), - }); - } else if (command === "REDACT") { - const target = parv[0]; - const msgid = parv[1]; - const reason = parv[2] ? parv[2].substring(1) : ""; // Remove leading : - const sender = getNickFromNuh(source); - this.triggerEvent("REDACT", { - serverId, - mtags, - target, - msgid, - reason, - sender, - }); - } else if (command === "RENAME") { - const user = getNickFromNuh(source); - const oldName = parv[0]; - const newName = parv[1]; - const reason = parv.slice(2).join(" "); // No need to remove leading : anymore - this.triggerEvent("RENAME", { - serverId, - oldName, - newName, - reason, - user, - }); - } else if (command === "SETNAME") { - const user = getNickFromNuh(source); - const realname = parv.join(" "); // No need to remove leading : anymore - this.triggerEvent("SETNAME", { - serverId, - user, - realname, - }); - } else if (command === "353") { - const channelName = parv[2]; - const namesStr = parv.slice(3).join(" ").trim(); - const names = namesStr.startsWith(":") - ? namesStr.substring(1) - : namesStr; - const newUsers = parseNamesResponse(names); // Parse the user list - - // Trigger an event to notify the UI - this.triggerEvent("NAMES", { - serverId, - channelName, - users: newUsers, - }); - } else if (command === "381") { - // RPL_YOUREOPER - You are now an IRC Operator - const message = parv.slice(1).join(" "); - this.triggerEvent("RPL_YOUREOPER", { - serverId, - message, - }); - } else if (command === "002") { - // RPL_YOURHOST - Your host is , running version - const message = parv.slice(1).join(" "); - // Parse the message: "Your host is , running version " - const match = message.match( - /Your host is ([^,]+), running version (.+)/, - ); - if (match) { - const serverName = match[1]; - const version = match[2]; - this.triggerEvent("RPL_YOURHOST", { - serverId, - serverName, - version, - }); - } - } else if (command === "324") { - // RPL_CHANNELMODEIS - Channel mode response - const channelName = parv[1]; - const modestring = parv[2] || ""; - const modeargs = parv.slice(3); - this.triggerEvent("RPL_CHANNELMODEIS", { - serverId, - channelName, - modestring, - modeargs, - }); - } else if (command === "CAP") { - console.log( - `[CAP] Processing CAP command, parv: ${JSON.stringify(parv)}, trailing: "${trailing}"`, - ); - console.log(`[CAP] Received CAP message: ${parv.join(" ")}`); - console.log(`[CAP] Full parv array: ${JSON.stringify(parv)}`); - console.log(`[CAP] Trailing parameter: "${trailing}"`); - let i = 0; - let caps = ""; - if (parv[i] === "*") { - i++; - } - let subcommand = parv[i++]; - console.log( - `[CAP] Subcommand: '${subcommand}', i after increment: ${i}, parv length: ${parv.length}`, - ); - // Handle CAP ACK which has nickname before subcommand - if ( - subcommand !== "LS" && - subcommand !== "ACK" && - subcommand !== "NEW" && - subcommand !== "DEL" && - subcommand !== "NAK" - ) { - // This is likely a nickname, skip it and get the real subcommand - subcommand = parv[i++]; - } - const isFinal = subcommand === "LS" && parv[i] !== "*"; - if (parv[i] === "*") i++; - - // Build caps string - use trailing parameter if available, otherwise join remaining parv - if (trailing) { - caps = trailing; - } else { - while (parv[i]) { - caps += parv[i++]; - if (parv[i]) caps += " "; - } - } - - console.log(`[CAP] Final caps string: "${caps}"`); - - if (subcommand === "LS") this.onCapLs(serverId, caps, isFinal); - else if (subcommand === "ACK") { - this.onCapAck(serverId, caps); - } else if (subcommand === "NAK") { - // Server rejected some capabilities, but we should still end CAP negotiation - this.sendRaw(serverId, "CAP END"); - this.capNegotiationComplete.set(serverId, true); - } else if (subcommand === "NEW") this.onCapNew(serverId, caps); - else if (subcommand === "DEL") this.onCapDel(serverId, caps); - else { - } - } else if (command === "005") { - const capabilities = parseIsupport(parv.join(" ")); - for (const [key, value] of Object.entries(capabilities)) { - if (key === "NETWORK") { - const server = this.servers.get(serverId); - if (server) { - server.networkName = value; - this.servers.set(serverId, server); - } - } - this.triggerEvent("ISUPPORT", { serverId, key, value }); - } - } else if (command === "AUTHENTICATE") { - const param = parv.join(" "); - this.triggerEvent("AUTHENTICATE", { serverId, param }); - } else if (command === "BATCH") { - // BATCH +reference-tag type [parameters...] or BATCH -reference-tag - const batchRef = parv[0]; - const isStart = batchRef.startsWith("+"); - const batchId = batchRef.substring(1); // Remove + or - - - if (isStart) { - const batchType = parv[1]; - const parameters = parv.slice(2); - - // Initialize batch tracking for this server if not exists - if (!this.activeBatches.has(serverId)) { - this.activeBatches.set(serverId, new Map()); - } - - // Track this batch - this.activeBatches.get(serverId)?.set(batchId, { - type: batchType, - parameters, - messages: [], - timestamps: [], - concatFlags: [], - messageIds: [], - batchMsgId: mtags?.msgid, // Store the msgid from the BATCH command itself - batchTime: mtags?.time ? new Date(mtags.time) : undefined, // Store the time from the BATCH command - }); - - this.triggerEvent("BATCH_START", { - serverId, - batchId, - type: batchType, - parameters, - }); - } else { - // Process completed batch - const serverBatches = this.activeBatches.get(serverId); - const batch = serverBatches?.get(batchId); - - if ( - batch && - (batch.type === "multiline" || batch.type === "draft/multiline") - ) { - // Handle completed multiline batch - // For multiline batches, parameters[0] is the target, sender comes from the PRIVMSG lines - const target = - batch.parameters && batch.parameters.length > 0 - ? batch.parameters[0] - : ""; - const sender = batch.sender || "unknown"; - - // Combine messages, handling draft/multiline-concat tags - let combinedMessage = ""; - batch.messages.forEach((message, index) => { - const wasConcat = batch.concatFlags?.[index]; - - if (index === 0) { - combinedMessage = message; - } else { - // Check if this message was tagged with draft/multiline-concat - if (wasConcat) { - // Concatenate directly without separator - combinedMessage += message; - } else { - // Join with newline (normal multiline) - combinedMessage += `\n${message}`; - } - } - }); - - this.triggerEvent("MULTILINE_MESSAGE", { - serverId, - mtags: batch.batchMsgId ? { msgid: batch.batchMsgId } : undefined, // Use the msgid from the BATCH command - sender, - channelName: target.startsWith("#") ? target : undefined, - message: combinedMessage, - lines: batch.messages, - messageIds: batch.messageIds || [], - timestamp: - batch.batchTime || - (batch.timestamps && batch.timestamps.length > 0 - ? new Date( - Math.min(...batch.timestamps.map((t) => t.getTime())), - ) - : getTimestampFromTags(mtags)), - }); - } - - // Clean up batch tracking - serverBatches?.delete(batchId); - - this.triggerEvent("BATCH_END", { - serverId, - batchId, - }); - } - } else if (command === "METADATA") { - // METADATA PARAM1 PARAM2 [PARAM3 PARAM4 etc optional params] :the actual value - // The trailing value is the last parameter, optional params can be between PARAM2 and value - const target = parv[0]; // PARAM1 - const key = parv[1]; // PARAM2 - - // The actual value is the last parameter (trailing parameter from original message) - const value = parv[parv.length - 1] || ""; - - // Everything between key and value are optional parameters (visibility, etc.) - const optionalParams = parv.length > 2 ? parv.slice(2, -1) : []; - - // For backward compatibility, assume first optional param is visibility if present - const visibility = optionalParams.length > 0 ? optionalParams[0] : ""; - - this.triggerEvent("METADATA", { - serverId, - target, - key, - visibility, - value, - }); - } else if (command === "760") { - // RPL_WHOISKEYVALUE - // RPL_WHOISKEYVALUE : - const target = parv[0]; - const key = parv[1]; - const visibility = parv[2]; - const value = parv.slice(3).join(" "); // No need to remove leading : anymore - this.triggerEvent("METADATA_WHOIS", { - serverId, - target, - key, - visibility, - value, - }); - } else if (command === "761") { - // RPL_KEYVALUE - // Format: 761 : - const recipient = parv[0]; // The user receiving this message (usually current user) - const target = parv[1]; // The user whose metadata this is - let key = parv[2]; - let visibility = parv[3]; - let valueStartIndex = 4; - - // If target is duplicated (server bug), adjust parsing - if (parv[1] === parv[2] && parv.length > 5) { - key = parv[3]; - visibility = parv[4]; - valueStartIndex = 5; - } - - const value = parv.slice(valueStartIndex).join(" "); - // Remove leading ":" if present - const cleanValue = value.startsWith(":") ? value.substring(1) : value; - - this.triggerEvent("METADATA_KEYVALUE", { - serverId, - target, - key, - visibility, - value: cleanValue, - }); - } else if (command === "766") { - // RPL_KEYNOTSET - // RPL_KEYNOTSET :key not set - const target = parv[0]; - const key = parv[1]; - this.triggerEvent("METADATA_KEYNOTSET", { serverId, target, key }); - } else if (command === "770") { - // RPL_METADATASUBOK - // Format: 770 [ ...] - const target = parv[0]; - const keys = parv - .slice(1) - .map((key) => (key.startsWith(":") ? key.substring(1) : key)); - this.triggerEvent("METADATA_SUBOK", { serverId, keys }); - } else if (command === "771") { - // RPL_METADATAUNSUBOK - // Format: 771 [ ...] - const target = parv[0]; - const keys = parv - .slice(1) - .map((key) => (key.startsWith(":") ? key.substring(1) : key)); - this.triggerEvent("METADATA_UNSUBOK", { serverId, keys }); - } else if (command === "772") { - // RPL_METADATASUBS - // Format: 772 [ ...] - const target = parv[0]; - const keys = parv - .slice(1) - .map((key) => (key.startsWith(":") ? key.substring(1) : key)); - this.triggerEvent("METADATA_SUBS", { serverId, keys }); - } else if (command === "774") { - // RPL_METADATASYNCLATER - // RPL_METADATASYNCLATER [] - const target = parv[0]; - const retryAfter = parv[1] ? Number.parseInt(parv[1], 10) : undefined; - this.triggerEvent("METADATA_SYNCLATER", { - serverId, - target, - retryAfter, - }); - } else if (command === "730") { - // RPL_MONONLINE - // Format: 730 :target[!user@host][,target[!user@host]]* - const targetList = parv.slice(1).join(" "); - const cleanTargetList = targetList.startsWith(":") - ? targetList.substring(1) - : targetList; - const targets = cleanTargetList.split(",").map((target) => { - const parts = target.split("!"); - if (parts.length === 2) { - const [nick, userhost] = parts; - const [user, host] = userhost.split("@"); - return { nick, user, host }; - } - return { nick: target }; - }); - this.triggerEvent("MONONLINE", { serverId, targets }); - } else if (command === "731") { - // RPL_MONOFFLINE - // Format: 731 :target[,target2]* - const targetList = parv.slice(1).join(" "); - const cleanTargetList = targetList.startsWith(":") - ? targetList.substring(1) - : targetList; - const targets = cleanTargetList.split(","); - this.triggerEvent("MONOFFLINE", { serverId, targets }); - } else if (command === "732") { - // RPL_MONLIST - // Format: 732 :target[,target2]* - const targetList = parv.slice(1).join(" "); - const cleanTargetList = targetList.startsWith(":") - ? targetList.substring(1) - : targetList; - const targets = cleanTargetList.split(","); - this.triggerEvent("MONLIST", { serverId, targets }); - } else if (command === "733") { - // RPL_ENDOFMONLIST - this.triggerEvent("ENDOFMONLIST", { serverId }); - } else if (command === "734") { - // ERR_MONLISTFULL - // Format: 734 :Monitor list is full. - const limit = Number.parseInt(parv[1], 10); - const targetList = parv[2]; - const targets = targetList.split(","); - this.triggerEvent("MONLISTFULL", { serverId, limit, targets }); - } else if (command === "FAIL" && parv[0] === "METADATA") { - // FAIL METADATA [] [] [] :[] - // ERR_METADATATOOMANY, ERR_METADATATARGETINVALID, ERR_METADATANOACCESS, ERR_METADATANOKEY, ERR_METADATARATELIMITED - const subcommand = parv[1]; // The METADATA subcommand that failed (SUB, SET, etc.) - const code = parv[2]; // The error code - - // Check if the last parameter is a trailing message (starts with original ":") - // If so, the parameters before it are the optional params - let paramCount = parv.length; - let errorMessage = ""; - - // If there are more than 3 params and the last one doesn't look like a number, - // it's likely a trailing error message - if (paramCount > 3) { - const lastParam = parv[paramCount - 1]; - if (lastParam && Number.isNaN(Number.parseInt(lastParam, 10))) { - errorMessage = lastParam; - paramCount = paramCount - 1; // Don't count the error message as a regular param - } - } - - let target: string | undefined; - let key: string | undefined; - let retryAfter: number | undefined; - - if (paramCount > 3) target = parv[3]; - if (paramCount > 4) key = parv[4]; - if (paramCount > 5 && code === "RATE_LIMITED") { - retryAfter = Number.parseInt(parv[5], 10); - } - - this.triggerEvent("METADATA_FAIL", { - serverId, - subcommand, - code, - target, - key, - retryAfter, - }); - } else if (command === "322") { - // RPL_LIST: : - const channelName = parv[1]; - const userCount = parv[2] ? Number.parseInt(parv[2], 10) : 0; - const topic = parv.slice(3).join(" "); // No need to remove leading : anymore - this.triggerEvent("LIST_CHANNEL", { - serverId, - channel: channelName, - userCount, - topic, - }); - } else if (command === "323") { - // RPL_LISTEND - this.triggerEvent("LIST_END", { serverId }); - } else if (command === "352") { - // RPL_WHOREPLY: : - // Note: hopcount and realname are in the trailing parameter together - const channel = parv[1]; - const username = parv[2]; - const host = parv[3]; - const server = parv[4]; - const nick = parv[5]; - const flags = parv[6]; - - // Parse trailing which contains "hopcount realname" - const trailing = parv[7] || ""; - const spaceIndex = trailing.indexOf(" "); - let hopcount = trailing; - let realname = ""; - - if (spaceIndex !== -1) { - hopcount = trailing.substring(0, spaceIndex); - realname = trailing.substring(spaceIndex + 1); - } - - this.triggerEvent("WHO_REPLY", { - serverId, - channel, - username, - host, - server, - nick, - flags, - hopcount, - realname, - }); - } else if (command === "354") { - // RPL_WHOSPCRPL (WHOX): Response format depends on requested fields - // Our request: WHO %cuhnfaro - // Response order: channel, username, hostname, nickname, flags, account, realname, op_level - // Example: 354 Valware #lobby ~u knqsza5faubzs.irc mattf H@ mattf * mattf - // Fields returned in order: c=channel, u=username, h=hostname, n=nickname, f=flags, a=account, r=realname, o=op_level - // Note: flags contains H/G (here/gone) followed by optional status symbols (@, +, etc) and other flags like * (IRC oper) or B (bot) - const channel = parv[1]; - const username = parv[2]; - const host = parv[3]; - const nick = parv[4]; - const flags = parv[5]; - const account = parv[6]; - const opLevelField = parv[7] || ""; - const realname = parv[8] || ""; // May be empty if not returned - - // Determine if user is away from flags (G=gone/away, H=here/present) - const isAway = flags.includes("G"); - - // Extract op level from flags field (everything after H or G) - // Flags format: H/G followed by status symbols like @, +, ~, %, & - // Also may include * (IRC operator) and B (bot) which we need to filter out - // Only keep valid channel status prefixes: @ + ~ % & - let opLevel = ""; - if (flags.length > 1) { - // Skip the first character (H or G) - const statusPart = flags.substring(1); - // Filter to only include valid channel prefixes - opLevel = statusPart - .split("") - .filter((char) => ["@", "+", "~", "%", "&"].includes(char)) - .join(""); - } - - this.triggerEvent("WHOX_REPLY", { - serverId, - channel, - username, - host, - nick, - account, - flags, - realname, - isAway, - opLevel, - }); - } else if (command === "305") { - // RPL_UNAWAY: : - // You are no longer marked as being away - const message = parv.slice(1).join(" "); - this.triggerEvent("RPL_UNAWAY", { - serverId, - message, - }); - } else if (command === "306") { - // RPL_NOWAWAY: : - // You have been marked as being away - const message = parv.slice(1).join(" "); - this.triggerEvent("RPL_NOWAWAY", { - serverId, - message, - }); - } else if (command === "301") { - // RPL_AWAY: : - // User is away - const nick = parv[1]; - const awayMessage = parv.slice(2).join(" "); - this.triggerEvent("RPL_AWAY", { - serverId, - nick, - awayMessage, - }); - } else if (command === "315") { - // RPL_ENDOFWHO - const mask = parv[1]; - this.triggerEvent("WHO_END", { serverId, mask }); - } else if (command === "335") { - // RPL_WHOISBOT: : - const nick = parv[0]; - const target = parv[1]; - const message = parv.slice(2).join(" "); // No need to remove leading : anymore - this.triggerEvent("WHOIS_BOT", { serverId, nick, target, message }); - } else if (command === "431") { - // ERR_NONICKNAMEGIVEN: :No nickname given - const message = parv.join(" "); // No need to remove leading : anymore - this.triggerEvent("NICK_ERROR", { - serverId, - code: "431", - error: "No nickname given", - message, - }); - } else if (command === "900") { - // RPL_LOGGEDIN: You are now logged in as - // This comes before SASL completion, don't end CAP negotiation yet - const message = parv.slice(2).join(" "); - } else if (command === "901" || command === "902" || command === "903") { - // SASL authentication successful - const message = parv.slice(2).join(" "); - // Finish capability negotiation - this.sendRaw(serverId, "CAP END"); - this.capNegotiationComplete.set(serverId, true); - this.userOnConnect(serverId); - } else if ( - command === "904" || - command === "905" || - command === "906" || - command === "907" - ) { - // SASL authentication failed - const message = parv.slice(2).join(" "); - // Still finish capability negotiation even if SASL failed - this.sendRaw(serverId, "CAP END"); - this.capNegotiationComplete.set(serverId, true); - this.userOnConnect(serverId); - } else if (command === "432") { - // ERR_ERRONEUSNICKNAME: :Erroneous nickname - const nick = parv[1]; - const message = parv.slice(2).join(" ").substring(1); - this.triggerEvent("NICK_ERROR", { - serverId, - code: "432", - error: "Invalid nickname", - nick, - message, - }); - } else if (command === "433") { - // ERR_NICKNAMEINUSE: :Nickname is already in use - const nick = parv[1]; - const message = parv.slice(2).join(" ").substring(1); - this.triggerEvent("NICK_ERROR", { - serverId, - code: "433", - error: "Nickname already in use", - nick, - message, - }); - } else if (command === "436") { - // ERR_NICKCOLLISION: :Nickname collision KILL from @ - const nick = parv[1]; - const message = parv.slice(2).join(" ").substring(1); - this.triggerEvent("NICK_ERROR", { - serverId, - code: "436", - error: "Nickname collision", - nick, - message, - }); - } else if (command === "FAIL") { - // Standard replies: FAIL : - const cmd = parv[0]; - const code = parv[1]; - const target = parv[2] || undefined; - const message = parv.slice(3).join(" ").substring(1); // Remove leading : - this.triggerEvent("FAIL", { - serverId, - mtags, - command: cmd, - code, - target, - message, - }); - } else if (command === "WARN") { - // Standard replies: WARN : - const cmd = parv[0]; - const code = parv[1]; - const target = parv[2] || undefined; - const message = parv.slice(3).join(" ").substring(1); // Remove leading : - this.triggerEvent("WARN", { - serverId, - mtags, - command: cmd, - code, - target, - message, - }); - } else if (command === "NOTE") { - // Standard replies: NOTE : - const cmd = parv[0]; - const code = parv[1]; - const target = parv[2] || undefined; - const message = parv.slice(3).join(" ").substring(1); // Remove leading : - this.triggerEvent("NOTE", { - serverId, - mtags, - command: cmd, - code, - target, - message, - }); - } else if (command === "SUCCESS") { - // Standard replies: SUCCESS : - const cmd = parv[0]; - const code = parv[1]; - const target = parv[2] || undefined; - const message = parv.slice(3).join(" ").substring(1); // Remove leading : - this.triggerEvent("SUCCESS", { - serverId, - mtags, - command: cmd, - code, - target, - message, - }); - } else if (command === "REGISTER") { - // Account registration responses - const subcommand = parv[0]; - if (subcommand === "SUCCESS") { - const account = parv[1]; - const message = parv.slice(2).join(" ").substring(1); - this.triggerEvent("REGISTER_SUCCESS", { - serverId, - mtags, - account, - message, - }); - } else if (subcommand === "VERIFICATION_REQUIRED") { - const account = parv[1]; - const message = parv.slice(2).join(" ").substring(1); - this.triggerEvent("REGISTER_VERIFICATION_REQUIRED", { - serverId, - mtags, - account, - message, - }); - } - } else if (command === "VERIFY") { - // Account verification responses - const subcommand = parv[0]; - if (subcommand === "SUCCESS") { - const account = parv[1]; - const message = parv.slice(2).join(" ").substring(1); - } - } else if (command === "367") { - // RPL_BANLIST: - const channel = parv[1]; - const mask = parv[2]; - const setter = parv[3]; - const timestamp = Number.parseInt(parv[4], 10); - console.log( - `IRC Client: RPL_BANLIST parsed - channel: ${channel}, mask: ${mask}, setter: ${setter}, timestamp: ${timestamp}`, - ); - this.triggerEvent("RPL_BANLIST", { - serverId, - channel, - mask, - setter, - timestamp, - }); - } else if (command === "346") { - // RPL_INVITELIST: - const channel = parv[1]; - const mask = parv[2]; - const setter = parv[3]; - const timestamp = Number.parseInt(parv[4], 10); - console.log( - `IRC Client: RPL_INVITELIST parsed - channel: ${channel}, mask: ${mask}, setter: ${setter}, timestamp: ${timestamp}`, - ); - this.triggerEvent("RPL_INVITELIST", { - serverId, - channel, - mask, - setter, - timestamp, - }); - } else if (command === "348") { - // RPL_EXCEPTLIST: - const channel = parv[1]; - const mask = parv[2]; - const setter = parv[3]; - const timestamp = Number.parseInt(parv[4], 10); - console.log( - `IRC Client: RPL_EXCEPTLIST parsed - channel: ${channel}, mask: ${mask}, setter: ${setter}, timestamp: ${timestamp}`, - ); - this.triggerEvent("RPL_EXCEPTLIST", { - serverId, - channel, - mask, - setter, - timestamp, - }); - } else if (command === "368") { - // RPL_ENDOFBANLIST: :End of channel ban list - const channel = parv[1]; - console.log( - `IRC Client: RPL_ENDOFBANLIST parsed - channel: ${channel}`, - ); - this.triggerEvent("RPL_ENDOFBANLIST", { - serverId, - channel, - }); - } else if (command === "347") { - // RPL_ENDOFINVITELIST: :End of channel invite list - const channel = parv[1]; - console.log( - `IRC Client: RPL_ENDOFINVITELIST parsed - channel: ${channel}`, - ); - this.triggerEvent("RPL_ENDOFINVITELIST", { - serverId, - channel, - }); - } else if (command === "349") { - // RPL_ENDOFEXCEPTLIST: :End of channel exception list - const channel = parv[1]; - console.log( - `IRC Client: RPL_ENDOFEXCEPTLIST parsed - channel: ${channel}`, - ); - this.triggerEvent("RPL_ENDOFEXCEPTLIST", { - serverId, - channel, - }); - } else if (command === "TOPIC") { - // TOPIC #channel :New topic text - const channelName = parv[0]; - const topic = parv.slice(1).join(" "); - const sender = getNickFromNuh(source); - this.triggerEvent("TOPIC", { - serverId, - channelName, - topic, - sender, - }); - } else if (command === "332") { - // RPL_TOPIC: : - const channelName = parv[1]; - const topic = parv.slice(2).join(" "); - this.triggerEvent("RPL_TOPIC", { - serverId, - channelName, - topic, - }); - } else if (command === "333") { - // RPL_TOPICWHOTIME: - const channelName = parv[1]; - const setter = parv[2]; - const timestamp = Number.parseInt(parv[3], 10); - this.triggerEvent("RPL_TOPICWHOTIME", { - serverId, - channelName, - setter, - timestamp, - }); - } else if (command === "331") { - // RPL_NOTOPIC: :No topic is set - const channelName = parv[1]; - this.triggerEvent("RPL_NOTOPIC", { - serverId, - channelName, - }); - } else if (command === "311") { - // RPL_WHOISUSER: * : - const nick = parv[1]; - const username = parv[2]; - const host = parv[3]; - const realname = parv.slice(5).join(" "); - this.triggerEvent("WHOIS_USER", { - serverId, - nick, - username, - host, - realname, - }); - } else if (command === "312") { - // RPL_WHOISSERVER: : - const nick = parv[1]; - const server = parv[2]; - const serverInfo = parv.slice(3).join(" "); - this.triggerEvent("WHOIS_SERVER", { - serverId, - nick, - server, - serverInfo, - }); - } else if (command === "317") { - // RPL_WHOISIDLE: :seconds idle, signon time - const nick = parv[1]; - const idle = Number.parseInt(parv[2], 10); - const signon = Number.parseInt(parv[3], 10); - this.triggerEvent("WHOIS_IDLE", { - serverId, - nick, - idle, - signon, - }); - } else if (command === "318") { - // RPL_ENDOFWHOIS: :End of /WHOIS list. - const nick = parv[1]; - this.triggerEvent("WHOIS_END", { - serverId, - nick, - }); - } else if (command === "319") { - // RPL_WHOISCHANNELS: : - const nick = parv[1]; - const channels = parv.slice(2).join(" "); - this.triggerEvent("WHOIS_CHANNELS", { - serverId, - nick, - channels, - }); - } else if (command === "320" || command === "378" || command === "379") { - // RPL_WHOISSPECIAL: Various special WHOIS lines - // 320: security groups, reputation, etc. - // 378: connecting from - // 379: using modes - const nick = parv[1]; - const message = parv.slice(2).join(" "); - this.triggerEvent("WHOIS_SPECIAL", { - serverId, - nick, - message, - }); - } else if (command === "330") { - // RPL_WHOISACCOUNT: :is logged in as - const nick = parv[1]; - const account = parv[2]; - this.triggerEvent("WHOIS_ACCOUNT", { - serverId, - nick, - account, - }); - } else if (command === "671") { - // RPL_WHOISSECURE: :is using a secure connection - const nick = parv[1]; - const message = parv.slice(2).join(" "); - this.triggerEvent("WHOIS_SECURE", { - serverId, - nick, - message, - }); - } else if (command === "EXTJWT") { - // EXTJWT [*] - const requestedTarget = parv[0]; - const serviceName = parv[1]; - // Check if there's a continuation marker (*) - let jwtToken: string; - if (parv[2] === "*") { - // Continuation format: EXTJWT target service * token - jwtToken = parv[3]; - } else { - // Normal format: EXTJWT target service token - jwtToken = parv[2]; - } - this.triggerEvent("EXTJWT", { - serverId, - requestedTarget, - serviceName, - jwtToken, - }); - } - } - } - - onCapLs(serverId: string, cliCaps: string, isFinal: boolean): void { - let accumulated = this.capLsAccumulated.get(serverId); - if (!accumulated) { - accumulated = new Set(); - this.capLsAccumulated.set(serverId, accumulated); - } - - const caps = cliCaps.split(" "); - for (const c of caps) { - const [cap, value] = c.split("=", 2); - accumulated.add(cap); - if (cap === "sasl" && value) { - const mechanisms = value.split(","); - this.saslMechanisms.set(serverId, mechanisms); - } - // Handle informational unrealircd.org/link-security capability - if (cap === "unrealircd.org/link-security" && value) { - const linkSecurityValue = Number.parseInt(value, 10) || 0; - // Trigger event with the link security value so the store can handle it - this.triggerEvent("CAP LS", { - serverId, - cliCaps: `unrealircd.org/link-security=${linkSecurityValue}`, - }); - } - } - - if (isFinal) { - // Now request the caps we want from the accumulated list - const capsToRequest: string[] = []; - const saslEnabled = this.saslEnabled.get(serverId) ?? false; - for (const cap of accumulated) { - if ( - (this.ourCaps.includes(cap) || cap.startsWith("draft/metadata")) && - (cap !== "sasl" || saslEnabled) - ) { - capsToRequest.push(cap); - } - } - - if (capsToRequest.length > 0) { - // Send capabilities in batches to avoid IRC line length limits (512 bytes) - let currentBatch: string[] = []; - const baseLength = "CAP REQ :".length + 2; // +2 for \r\n - let currentLength = baseLength; - let batchCount = 0; - - for (const cap of capsToRequest) { - const capLength = cap.length + (currentBatch.length > 0 ? 1 : 0); // +1 for space if not first - - if (currentLength + capLength > 500 && currentBatch.length > 0) { - // Leave some margin - // Send current batch - const reqMessage = `CAP REQ :${currentBatch.join(" ")}`; - this.sendRaw(serverId, reqMessage); - batchCount++; - currentBatch = []; - currentLength = baseLength; - } - - currentBatch.push(cap); - currentLength += capLength; - } - - // Send remaining batch - if (currentBatch.length > 0) { - const reqMessage = `CAP REQ :${currentBatch.join(" ")}`; - this.sendRaw(serverId, reqMessage); - batchCount++; - } - - // Track how many CAP REQ batches we sent - this.pendingCapReqs.set(serverId, batchCount); - - // Set a timeout to send CAP END if server doesn't respond - setTimeout(() => { - if (this.pendingCapReqs.has(serverId)) { - this.pendingCapReqs.delete(serverId); - this.sendRaw(serverId, "CAP END"); - this.capNegotiationComplete.set(serverId, true); - this.userOnConnect(serverId); - } - }, 5000); // 5 second timeout - - if (capsToRequest.includes("draft/extended-isupport")) { - this.sendRaw(serverId, "ISUPPORT"); - } - } - // Clean up - this.capLsAccumulated.delete(serverId); - } - } - - onCapNew(serverId: string, cliCaps: string): void { - const caps = cliCaps.split(" "); - for (const c of caps) { - const [cap, value] = c.split("=", 2); - if (cap === "sasl" && value) { - const mechanisms = value.split(","); - this.saslMechanisms.set(serverId, mechanisms); - // If sasl becomes available, perhaps request it if not already - // But for now, just log - } - } - } - - onCapDel(serverId: string, cliCaps: string): void { - const caps = cliCaps.split(" "); - for (const c of caps) { - const [cap] = c.split("=", 2); - if (cap === "sasl") { - this.saslMechanisms.delete(serverId); - } - } - } - - onCapAck(serverId: string, cliCaps: string): void { - // Trigger the original event for compatibility - this.triggerEvent("CAP ACK", { serverId, cliCaps }); - - // Decrement pending CAP REQ count - const pendingCount = this.pendingCapReqs.get(serverId) || 0; - if (pendingCount > 0) { - const newCount = pendingCount - 1; - - if (newCount === 0) { - // All CAP REQ batches acknowledged - this.pendingCapReqs.delete(serverId); - - // Note: SASL authentication is handled by the store's event handlers - // The store will check capabilities and initiate SASL if needed - } else { - this.pendingCapReqs.set(serverId, newCount); - } - } else { - } - } - - on(event: K, callback: EventCallback): void { - if (!this.eventCallbacks[event]) { - this.eventCallbacks[event] = []; - } - this.eventCallbacks[event]?.push(callback); - } - - deleteHook(event: K, callback: EventCallback): void { - const cbs = this.eventCallbacks[event]; - if (!cbs) return; - const index = cbs.indexOf(callback); - if (index !== -1) { - cbs.splice(index, 1); - } - } - - triggerEvent(event: K, data: EventMap[K]): void { - const cbs = this.eventCallbacks[event]; - if (!cbs) return; - for (const cb of cbs) { - cb(data); - } - } - - getServers(): Server[] { - return Array.from(this.servers.values()); - } - - getCurrentUser(serverId?: string): User | null { - // If no serverId provided, return null (we need server context now) - if (!serverId) return null; - return this.currentUsers.get(serverId) || null; - } - - getAllUsers(serverId: string): User[] { - const server = this.servers.get(serverId); - if (!server) return []; - - const allUsers = new Map(); - - // Collect users from all joined channels - for (const channel of server.channels) { - for (const user of channel.users) { - allUsers.set(user.username, user); - } - } - - return Array.from(allUsers.values()); - } -} - -function getNickFromNuh(nuh: string) { - const nick = nuh.split("!")[0]; - return nick.startsWith(":") ? nick.substring(1) : nick; -} - -function getTimestampFromTags(mtags: Record | undefined): Date { - if (mtags?.time) { - return new Date(mtags.time); - } - return new Date(); -} - -export const ircClient = new IRCClient(); - -export default ircClient; +} from "./irc/utils/ircUtils"; diff --git a/src/lib/ircUtils.tsx b/src/lib/ircUtils.tsx index 8ea3c764..a8b8742d 100644 --- a/src/lib/ircUtils.tsx +++ b/src/lib/ircUtils.tsx @@ -632,13 +632,20 @@ function processUrlsInText( // Truncate long URLs for display const displayText = url.length > 50 ? `${url.slice(0, 47)}...` : url; + // Add security class for external HTTP/HTTPS links + const isExternalLink = + fullUrl.startsWith("http://") || fullUrl.startsWith("https://"); + const linkClass = isExternalLink + ? "text-blue-500 hover:text-blue-700 underline external-link-security" + : "text-blue-500 hover:text-blue-700 underline"; + parts.push( diff --git a/src/protocol/isupport.ts b/src/protocol/isupport.ts index 8b945c5c..efe3bb26 100644 --- a/src/protocol/isupport.ts +++ b/src/protocol/isupport.ts @@ -1,10 +1,11 @@ +import type { StoreApi, UseBoundStore } from "zustand"; import type { IRCClient } from "../lib/ircClient"; -import type AppState from "../store/"; +import type { AppState } from "../store/"; import type { Server } from "../types/"; export function registerISupportHandler( ircClient: IRCClient, - useStore: typeof AppState, + useStore: UseBoundStore>, ) { ircClient.on("ISUPPORT", ({ serverId, key, value }) => { if (key === "FAVICON" || key === "ICON" || key === "draft/ICON") { diff --git a/src/protocol/mode.ts b/src/protocol/mode.ts index afbd8535..fb31aeaf 100644 --- a/src/protocol/mode.ts +++ b/src/protocol/mode.ts @@ -1,10 +1,11 @@ +import type { StoreApi, UseBoundStore } from "zustand"; import type { IRCClient } from "../lib/ircClient"; -import type AppState from "../store/"; +import type { AppState } from "../store/"; import type { Channel, Message, Server } from "../types/"; export function registerModeHandler( ircClient: IRCClient, - useStore: typeof AppState, + useStore: UseBoundStore>, ) { ircClient.on("MODE", ({ serverId, sender, target, modestring, modeargs }) => { const state = useStore.getState(); @@ -36,7 +37,7 @@ function handleChannelMode( sender: string, modestring: string, modeargs: string[], - useStore: typeof AppState, + useStore: UseBoundStore>, ) { const channel = server.channels.find((c: Channel) => c.name === channelName); if (!channel) return; @@ -124,7 +125,7 @@ function applyModeChanges( serverId: string, channel: Channel, changes: ModeChange[], - useStore: typeof AppState, + useStore: UseBoundStore>, ) { const state = useStore.getState(); const server = state.servers.find((s: Server) => s.id === serverId); @@ -185,7 +186,7 @@ function updateChannelModes( serverId: string, channel: Channel, changes: ModeChange[], - useStore: typeof AppState, + useStore: UseBoundStore>, ) { // Only update if there are actual channel mode changes (not just user status changes) const channelModeChanges = changes.filter( @@ -337,7 +338,7 @@ function applyListModeChanges( channel: Channel, sender: string, changes: ModeChange[], - useStore: typeof AppState, + useStore: UseBoundStore>, ) { useStore.setState((state) => { const updatedServers = state.servers.map((s: Server) => { @@ -475,7 +476,7 @@ function sendModeNotification( channel: Channel, sender: string, changes: ModeChange[], - useStore: typeof AppState, + useStore: UseBoundStore>, ) { // Group changes by action and reconstruct compact mode strings const groupedChanges: { [action: string]: ModeChange[] } = {}; diff --git a/src/store/adapters/ircAdapter.ts b/src/store/adapters/ircAdapter.ts new file mode 100644 index 00000000..2775f70c --- /dev/null +++ b/src/store/adapters/ircAdapter.ts @@ -0,0 +1,636 @@ +import { v4 as uuidv4 } from "uuid"; +import type { StoreApi } from "zustand"; +import { isUserIgnored } from "../../lib/ignoreUtils"; +import ircClient from "../../lib/ircClient"; +import { + playNotificationSound, + shouldPlayNotificationSound, +} from "../../lib/notificationSounds"; +import { + checkForMention, + extractMentions, + showMentionNotification, +} from "../../lib/notifications"; +import type { AppState } from "../index"; +import { findChannelMessageById } from "../index"; + +/** + * IRC Event Handler Adapter + * + * This module separates IRC protocol handling from state management. + * All IRC event handlers are registered here and update the store via actions. + * + * Benefits: + * - Clear separation of concerns + * - Easier to test + * - Centralized IRC logic + * - No protocol details in store slices + */ + +// Helper to get current UI selection +function getCurrentSelection(state: AppState) { + const serverId = state.ui.selectedServerId; + if (!serverId) + return { selectedChannelId: null, selectedPrivateChatId: null }; + + return ( + state.ui.perServerSelections[serverId] || { + selectedChannelId: null, + selectedPrivateChatId: null, + } + ); +} + +// Helper to get server selection +function getServerSelection(state: AppState, serverId: string) { + return ( + state.ui.perServerSelections[serverId] || { + selectedChannelId: null, + selectedPrivateChatId: null, + } + ); +} + +// Helper to set server selection +function setServerSelection( + state: AppState, + serverId: string, + selection: { + selectedChannelId: string | null; + selectedPrivateChatId: string | null; + }, +) { + return { + ...state.ui.perServerSelections, + [serverId]: selection, + }; +} + +export function initializeIRCEventHandlers(store: StoreApi) { + const { getState } = store; + + console.log("IRC Adapter: Initializing event handlers..."); + + // ============================================================================ + // CONNECTION MANAGEMENT + // ============================================================================ + + ircClient.on("connectionStateChange", ({ serverId, connectionState }) => { + const state = getState(); + + // Update connection state + state.setConnectionState(serverId, connectionState); + + // If a server just connected and we have no selected server (showing welcome screen), + // switch back to this server to maintain continuity during reconnection + if (connectionState === "connected" && state.ui.selectedServerId === null) { + const reconnectedServer = state.getServer(serverId); + if (reconnectedServer) { + const serverSelection = getServerSelection(state, serverId); + state.setSelectedServerId(serverId); + state.setPerServerSelection(serverId, serverSelection); + } + } + }); + + ircClient.on("ready", async ({ serverId, serverName, nickname }) => { + // Note: restoreServerMetadata() will be called here once we migrate that function + console.log(`Server ready: ${serverName} (${serverId}) as ${nickname}`); + }); + + // ============================================================================ + // MESSAGE HANDLING + // ============================================================================ + + ircClient.on("CHANMSG", (response) => { + const { mtags, channelName, message, timestamp } = response; + const state = getState(); + + // Check for duplicate messages based on msgid + if (mtags?.msgid && state.isMessageProcessed(mtags.msgid)) { + console.log(`Skipping duplicate message with msgid: ${mtags.msgid}`); + return; + } + + // Check if sender is ignored + if ( + isUserIgnored( + response.sender, + undefined, + undefined, + state.globalSettings.ignoreList, + ) + ) { + return; + } + + // Find the server and channel + const server = state.getServer(response.serverId); + if (!server) return; + + const channel = state.getChannelByName(response.serverId, channelName); + if (!channel) return; + + // Handle reply messages + const replyId = mtags?.["+draft/reply"]?.trim() || null; + const replyMessage = replyId + ? findChannelMessageById(server.id, channel.id, replyId) || null + : null; + + // Check for mentions + const currentServerUser = ircClient.getCurrentUser(response.serverId); + const isOwnMessage = response.sender === currentServerUser?.username; + const hasMention = + !isOwnMessage && + checkForMention(message, currentServerUser, state.globalSettings); + const mentions = !isOwnMessage + ? extractMentions(message, currentServerUser, state.globalSettings) + : []; + + const newMessage = { + id: uuidv4(), + msgid: mtags?.msgid, + content: message, + timestamp, + userId: response.sender, + channelId: channel.id, + serverId: server.id, + type: "message" as const, + reactions: [], + replyMessage: replyMessage, + mentioned: mentions, + tags: mtags, + }; + + // Update channel unread count and mention flag if not the active channel + const currentSelection = getCurrentSelection(state); + const isActiveChannel = + currentSelection.selectedChannelId === channel.id && + state.ui.selectedServerId === server.id; + + // Don't count unread/mentions for historical messages (batch tag indicates chathistory playback) + const isHistoricalMessage = mtags?.batch !== undefined; + + if ( + !isActiveChannel && + response.sender !== currentServerUser?.username && + !isHistoricalMessage + ) { + state.updateChannel(server.id, channel.id, { + unreadCount: channel.unreadCount + 1, + isMentioned: hasMention || channel.isMentioned, + }); + + // Show browser notification for mentions + if (hasMention && state.globalSettings.enableNotifications) { + showMentionNotification( + server.id, + channelName, + response.sender, + message, + (serverId, msg) => { + state.addGlobalNotification({ + type: "note", + command: "MENTION", + code: "HIGHLIGHT", + message: msg, + serverId, + }); + }, + ); + } + } + + // If message has bot tag, mark user as bot + if (mtags?.bot !== undefined) { + state.updateUserInChannel( + response.serverId, + channelName, + response.sender, + { + isBot: true, + metadata: { + ...state.getUserInChannel( + response.serverId, + channelName, + response.sender, + )?.metadata, + bot: { value: "true", visibility: "public" }, + }, + }, + ); + } + + // Add the message + state.addMessage(newMessage); + + // Play notification sound if appropriate (but not for historical messages) + if (!isHistoricalMessage) { + const serverCurrentUser = ircClient.getCurrentUser(response.serverId); + if ( + shouldPlayNotificationSound( + newMessage, + serverCurrentUser, + state.globalSettings, + ) + ) { + playNotificationSound(state.globalSettings); + } + } + + // Mark this message ID as processed to prevent duplicates + if (mtags?.msgid) { + state.markMessageAsProcessed(mtags.msgid); + } + + // Remove any typing users from the state + state.removeTypingUser(`${server.id}-${channel.id}`, response.sender); + }); + + ircClient.on("USERMSG", (response) => { + const { mtags, sender, target, message, timestamp } = response; + const state = getState(); + + // Check for duplicate messages + if (mtags?.msgid && state.isMessageProcessed(mtags.msgid)) { + console.log( + `Skipping duplicate private message with msgid: ${mtags.msgid}`, + ); + return; + } + + // Check if sender is ignored + if ( + isUserIgnored( + sender, + undefined, + undefined, + state.globalSettings.ignoreList, + ) + ) { + return; + } + + const server = state.getServer(response.serverId); + if (!server) return; + + const currentServerUser = ircClient.getCurrentUser(response.serverId); + const isOwnMessage = sender === currentServerUser?.username; + + // Determine the other party in the conversation + const otherParty = isOwnMessage ? target : sender; + + // Find or create private chat + let privateChat = state.getPrivateChatByUsername( + response.serverId, + otherParty, + ); + if (!privateChat) { + privateChat = { + id: uuidv4(), + username: otherParty, + serverId: response.serverId, + unreadCount: 0, + isMentioned: false, + isPinned: false, + order: 0, + }; + state.addPrivateChatToServer(response.serverId, privateChat); + } + + // Check for mentions and replies (similar to channel messages) + const hasMention = + !isOwnMessage && + checkForMention(message, currentServerUser, state.globalSettings); + const mentions = !isOwnMessage + ? extractMentions(message, currentServerUser, state.globalSettings) + : []; + + const replyId = mtags?.["+draft/reply"]?.trim() || null; + const replyMessage = null; // TODO: implement reply lookup for private messages + + const newMessage = { + id: uuidv4(), + msgid: mtags?.msgid, + content: message, + timestamp, + userId: sender, + channelId: privateChat.id, // For private messages, channelId is the privateChatId + serverId: server.id, + type: "message" as const, + reactions: [], + replyMessage: replyMessage, + mentioned: mentions, + tags: mtags, + }; + + // Update unread count if not the active private chat + const currentSelection = getCurrentSelection(state); + const isActivePrivateChat = + currentSelection.selectedPrivateChatId === privateChat.id && + state.ui.selectedServerId === server.id; + + const isHistoricalMessage = mtags?.batch !== undefined; + + if ( + !isActivePrivateChat && + sender !== currentServerUser?.username && + !isHistoricalMessage + ) { + state.updatePrivateChat(server.id, privateChat.id, { + unreadCount: (privateChat.unreadCount || 0) + 1, + isMentioned: hasMention || privateChat.isMentioned, + }); + + // Show browser notification + if (state.globalSettings.enableNotifications) { + showMentionNotification( + server.id, + sender, + sender, + message, + (serverId, msg) => { + state.addGlobalNotification({ + type: "note", + command: "PRIVMSG", + code: "PM", + message: msg, + serverId, + }); + }, + ); + } + } + + // Add the message + state.addMessage(newMessage); + + // Play notification sound if appropriate + if (!isHistoricalMessage) { + const serverCurrentUser = ircClient.getCurrentUser(response.serverId); + if ( + shouldPlayNotificationSound( + newMessage, + serverCurrentUser, + state.globalSettings, + ) + ) { + playNotificationSound(state.globalSettings); + } + } + + // Mark as processed + if (mtags?.msgid) { + state.markMessageAsProcessed(mtags.msgid); + } + + // Remove typing indicator + if (privateChat) { + state.removeTypingUser(`${server.id}-${privateChat.id}`, sender); + } + }); + + // ============================================================================ + // USER MANAGEMENT + // ============================================================================ + + ircClient.on( + "JOIN", + ({ serverId, username, channelName, batchTag, account, realname }) => { + const state = getState(); + const server = state.getServer(serverId); + if (!server) return; + + const channel = state.getChannelByName(serverId, channelName); + if (!channel) return; + + // If this event is part of a batch, store it for later processing + if (batchTag) { + const batch = state.getActiveBatch(serverId, batchTag); + if (batch) { + // Store the event for batch processing + // TODO: implement batch event storage + } + return; + } + + // Check if user already exists + const existingUser = state.getUserInChannel( + serverId, + channelName, + username, + ); + if (existingUser) { + // User already exists, possibly update their info + return; + } + + // Add the user to the channel + const newUser = { + id: uuidv4(), + username, + modes: "", + account: account || undefined, + realname: realname || undefined, + isBot: false, + isOnline: true, + metadata: {}, + }; + + state.addUserToChannel(serverId, channelName, newUser); + + // Add system message + const joinMessage = { + id: uuidv4(), + content: `${username} has joined ${channelName}`, + timestamp: new Date(Date.now()), + userId: username, + channelId: channel.id, + serverId: server.id, + type: "join" as const, + reactions: [], + replyMessage: null, + mentioned: [], + }; + + state.addMessage(joinMessage); + }, + ); + + ircClient.on("PART", ({ serverId, username, channelName, reason }) => { + const state = getState(); + const server = state.getServer(serverId); + if (!server) return; + + const channel = state.getChannelByName(serverId, channelName); + if (!channel) return; + + // Remove user from channel + state.removeUserFromChannel(serverId, channelName, username); + + // Add system message + const partMessage = { + id: uuidv4(), + content: `${username} has left ${channelName}${reason ? ` (${reason})` : ""}`, + timestamp: new Date(Date.now()), + userId: username, + channelId: channel.id, + serverId: server.id, + type: "part" as const, + reactions: [], + replyMessage: null, + mentioned: [], + }; + + state.addMessage(partMessage); + }); + + ircClient.on("QUIT", ({ serverId, username, reason, batchTag }) => { + const state = getState(); + const server = state.getServer(serverId); + if (!server) return; + + // If this event is part of a batch, store it for later processing + if (batchTag) { + const batch = state.getActiveBatch(serverId, batchTag); + if (batch) { + // TODO: Store batch event + } + return; + } + + // Remove user from all channels on this server + for (const channel of server.channels) { + const user = state.getUserInChannel(serverId, channel.name, username); + if (user) { + state.removeUserFromChannel(serverId, channel.name, username); + + // Add system message + const quitMessage = { + id: uuidv4(), + content: `${username} has quit${reason ? ` (${reason})` : ""}`, + timestamp: new Date(Date.now()), + userId: username, + channelId: channel.id, + serverId: server.id, + type: "quit" as const, + reactions: [], + replyMessage: null, + mentioned: [], + }; + + state.addMessage(quitMessage); + } + } + }); + + ircClient.on("NICK", ({ serverId, oldNick, newNick }) => { + const state = getState(); + const server = state.getServer(serverId); + if (!server) return; + + // Update username in all channels + for (const channel of server.channels) { + const user = state.getUserInChannel(serverId, channel.name, oldNick); + if (user) { + // Add new user with updated username + state.addUserToChannel(serverId, channel.name, { + ...user, + username: newNick, + }); + + // Remove old user + state.removeUserFromChannel(serverId, channel.name, oldNick); + + // Add system message + const nickMessage = { + id: uuidv4(), + content: `${oldNick} is now known as ${newNick}`, + timestamp: new Date(Date.now()), + userId: oldNick, + channelId: channel.id, + serverId: server.id, + type: "nick" as const, + reactions: [], + replyMessage: null, + mentioned: [], + }; + + state.addMessage(nickMessage); + } + } + + // Update private chats + const privateChat = state.getPrivateChatByUsername(serverId, oldNick); + if (privateChat) { + state.updatePrivateChat(serverId, privateChat.id, { + username: newNick, + }); + } + }); + + ircClient.on( + "KICK", + ({ serverId, username, target, channelName, reason }) => { + const state = getState(); + const server = state.getServer(serverId); + if (!server) return; + + const channel = state.getChannelByName(serverId, channelName); + if (!channel) return; + + // Remove user from channel + state.removeUserFromChannel(serverId, channelName, target); + + // Add system message + const kickMessage = { + id: uuidv4(), + content: `${target} was kicked by ${username}${reason ? ` (${reason})` : ""}`, + timestamp: new Date(Date.now()), + userId: username, + channelId: channel.id, + serverId: server.id, + type: "kick" as const, + reactions: [], + replyMessage: null, + mentioned: [], + }; + + state.addMessage(kickMessage); + }, + ); + + // ============================================================================ + // TODO: Additional handlers to be implemented + // ============================================================================ + + // The following handlers still need to be migrated from the old store: + // - MULTILINE_MESSAGE + // - CHANNNOTICE, USERNOTICE + // - TOPIC, RPL_TOPIC, RPL_NOTOPIC, RPL_TOPICWHOTIME + // - MODE, RPL_CHANNELMODEIS + // - NAMES, WHO_END + // - WHOIS_* (USER, SERVER, IDLE, CHANNELS, ACCOUNT, SECURE, SPECIAL, END, BOT) + // - INVITE + // - LIST_CHANNEL, LIST_END + // - TAGMSG (typing notifications, reactions) + // - REDACT (message deletion) + // - METADATA, METADATA_*, KEYVALUE, KEYNOTSET, SUBOK, UNSUBOK, SUBS + // - BATCH_START, BATCH_END + // - CAP_*, AUTHENTICATE + // - FAIL, WARN, NOTE + // - NICK_ERROR + // - REGISTER_SUCCESS, REGISTER_VERIFICATION_REQUIRED, VERIFY_SUCCESS + // - RPL_YOURHOST, RPL_YOUREOPER + // - RPL_AWAY, RPL_NOWAWAY, RPL_UNAWAY + // - RPL_BANLIST, RPL_INVITELIST, RPL_EXCEPTLIST, RPL_ENDOFBANLIST, RPL_ENDOFINVITELIST + // - MONOFFLINE, MONONLINE + // - AWAY, CHGHOST, SETNAME, RENAME + // - CHATHISTORY_LOADING + // - EXTJWT + + console.log("IRC Adapter: Core event handlers initialized"); + console.log( + "TODO: Additional handlers (MULTILINE, notices, metadata, etc.) to be implemented", + ); +} diff --git a/src/store/index.old.backup b/src/store/index.old.backup new file mode 100644 index 00000000..7c9eb338 --- /dev/null +++ b/src/store/index.old.backup @@ -0,0 +1,8099 @@ +import { v4 as uuidv4 } from "uuid"; +import { create } from "zustand"; +import { isUserIgnored } from "../lib/ignoreUtils"; +import ircClient from "../lib/ircClient"; +import { + playNotificationSound, + shouldPlayNotificationSound, +} from "../lib/notificationSounds"; +import { + checkForMention, + extractMentions, + showMentionNotification, +} from "../lib/notifications"; +import { registerAllProtocolHandlers } from "../protocol"; +import type { + Channel, + Message, + PrivateChat, + Server, + ServerConfig, + User, + WhoisData, +} from "../types"; + +const LOCAL_STORAGE_SERVERS_KEY = "savedServers"; +const LOCAL_STORAGE_METADATA_KEY = "serverMetadata"; +const LOCAL_STORAGE_SETTINGS_KEY = "globalSettings"; +const LOCAL_STORAGE_CHANNEL_ORDER_KEY = "channelOrder"; +const LOCAL_STORAGE_PINNED_PMS_KEY = "pinnedPrivateChats"; + +// Type for saved metadata structure: serverId -> target -> key -> metadata +type SavedMetadata = Record< + string, + Record> +>; + +// Type for pinned private chats: serverId -> array of {username, order} +type PinnedPrivateChatsMap = Record< + string, + Array<{ username: string; order: number }> +>; + +// Type for channel order: serverId -> array of channel names in order +type ChannelOrderMap = Record; + +// Types for batch event processing +interface JoinBatchEvent { + type: "JOIN"; + data: { + serverId: string; + username: string; + channelName: string; + account?: string; // From extended-join + realname?: string; // From extended-join + }; +} + +interface QuitBatchEvent { + type: "QUIT"; + data: { + serverId: string; + username: string; + reason: string; + }; +} + +interface PartBatchEvent { + type: "PART"; + data: { + serverId: string; + username: string; + channelName: string; + reason?: string; + }; +} + +type BatchEvent = JoinBatchEvent | QuitBatchEvent | PartBatchEvent; + +interface BatchInfo { + type: string; + parameters?: string[]; + events: BatchEvent[]; + startTime: Date; +} + +interface Attachment { + id: string; + type: "image"; + url: string; + filename: string; +} + +export const getChannelMessages = (serverId: string, channelId: string) => { + const state = useStore.getState(); + const key = `${serverId}-${channelId}`; + return state.messages[key] || []; +}; + +export const findChannelMessageById = ( + serverId: string, + channelId: string, + messageId: string, +): Message | undefined => { + const messages = getChannelMessages(serverId, channelId); + return messages.find((message) => message.msgid === messageId); +}; +// Load saved servers from localStorage +export function loadSavedServers(): ServerConfig[] { + return JSON.parse(localStorage.getItem(LOCAL_STORAGE_SERVERS_KEY) || "[]"); +} + +// Load saved metadata from localStorage +export function loadSavedMetadata(): SavedMetadata { + return JSON.parse(localStorage.getItem(LOCAL_STORAGE_METADATA_KEY) || "{}"); +} + +// Save metadata to localStorage +function saveMetadataToLocalStorage(metadata: SavedMetadata) { + localStorage.setItem(LOCAL_STORAGE_METADATA_KEY, JSON.stringify(metadata)); +} + +// Load saved global settings from localStorage +function loadSavedGlobalSettings(): Partial { + try { + return JSON.parse(localStorage.getItem(LOCAL_STORAGE_SETTINGS_KEY) || "{}"); + } catch { + return {}; + } +} + +// Save global settings to localStorage +function saveGlobalSettingsToLocalStorage(settings: GlobalSettings) { + localStorage.setItem(LOCAL_STORAGE_SETTINGS_KEY, JSON.stringify(settings)); +} + +// Load channel order from localStorage +function loadChannelOrder(): ChannelOrderMap { + return JSON.parse( + localStorage.getItem(LOCAL_STORAGE_CHANNEL_ORDER_KEY) || "{}", + ); +} + +// Save channel order to localStorage +function saveChannelOrder(channelOrder: ChannelOrderMap) { + localStorage.setItem( + LOCAL_STORAGE_CHANNEL_ORDER_KEY, + JSON.stringify(channelOrder), + ); +} + +// Load pinned private chats from localStorage +function loadPinnedPrivateChats(): PinnedPrivateChatsMap { + try { + return JSON.parse( + localStorage.getItem(LOCAL_STORAGE_PINNED_PMS_KEY) || "{}", + ); + } catch { + return {}; + } +} + +// Save pinned private chats to localStorage +function savePinnedPrivateChats(pinnedChats: PinnedPrivateChatsMap) { + localStorage.setItem( + LOCAL_STORAGE_PINNED_PMS_KEY, + JSON.stringify(pinnedChats), + ); +} + +// Check if a server supports metadata +function serverSupportsMetadata(serverId: string): boolean { + const state = useStore.getState(); + const server = state.servers.find((s) => s.id === serverId); + const supports = + server?.capabilities?.some( + (cap) => cap === "draft/metadata-2" || cap.startsWith("draft/metadata"), + ) ?? false; + return supports; +} + +// Check if a server supports multiline +function serverSupportsMultiline(serverId: string): boolean { + const state = useStore.getState(); + const server = state.servers.find((s) => s.id === serverId); + const supports = server?.capabilities?.includes("draft/multiline") ?? false; + return supports; +} + +export { serverSupportsMetadata, serverSupportsMultiline }; + +function saveServersToLocalStorage(servers: ServerConfig[]) { + localStorage.setItem(LOCAL_STORAGE_SERVERS_KEY, JSON.stringify(servers)); +} + +// Export the function +export { saveServersToLocalStorage }; + +// Restore metadata for a server from localStorage +function restoreServerMetadata(serverId: string) { + const savedMetadata = loadSavedMetadata(); + const serverMetadata = savedMetadata[serverId]; + if (!serverMetadata) return; + + useStore.setState((state) => { + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + // Restore server metadata + const updatedMetadata = { ...server.metadata }; + if (serverMetadata[server.name]) { + Object.assign(updatedMetadata, serverMetadata[server.name]); + } + + // Restore user metadata in channels + const updatedChannels = server.channels.map((channel) => { + const updatedUsers = channel.users.map((user) => { + const userMetadata = serverMetadata[user.username]; + if (userMetadata) { + return { + ...user, + metadata: { ...user.metadata, ...userMetadata }, + }; + } + return user; + }); + + // Restore channel metadata + const channelMetadata = serverMetadata[channel.name]; + const updatedChannelMetadata = channel.metadata || {}; + if (channelMetadata) { + Object.assign(updatedChannelMetadata, channelMetadata); + } + + return { + ...channel, + users: updatedUsers, + metadata: updatedChannelMetadata, + }; + }); + + return { + ...server, + metadata: updatedMetadata, + channels: updatedChannels, + }; + } + return server; + }); + + // Restore current user metadata + let updatedCurrentUser = state.currentUser; + if (state.currentUser && serverMetadata[state.currentUser.username]) { + updatedCurrentUser = { + ...state.currentUser, + metadata: { + ...state.currentUser.metadata, + ...serverMetadata[state.currentUser.username], + }, + }; + } + + return { servers: updatedServers, currentUser: updatedCurrentUser }; + }); +} + +// Fetch our own metadata from the server and update saved values +async function fetchAndMergeOwnMetadata(serverId: string): Promise { + return new Promise((resolve) => { + const nickname = ircClient.getNick(serverId); + if (!nickname) { + resolve(); + return; + } + + // Mark as fetching + useStore.setState((state) => ({ + metadataFetchInProgress: { + ...state.metadataFetchInProgress, + [serverId]: true, + }, + })); + + // Request all metadata for ourselves (target "*" means us) + const defaultKeys = [ + "url", + "website", + "status", + "location", + "avatar", + "color", + "display-name", + ]; + + // Get our metadata from the server + ircClient.metadataGet(serverId, "*", defaultKeys); + + // Wait a bit for responses to come in, then resolve + // The METADATA_KEYVALUE handler will update saved values + setTimeout(() => { + useStore.setState((state) => ({ + metadataFetchInProgress: { + ...state.metadataFetchInProgress, + [serverId]: false, + }, + })); + resolve(); + }, 1000); + }); +} + +// Fetch channel metadata for the channel list modal +// Uses caching to avoid refetching and rate limiting +function fetchChannelMetadata(serverId: string, channelNames: string[]) { + const state = useStore.getState(); + const now = Date.now(); + const CACHE_TTL = 5 * 60 * 1000; // 5 minutes cache + + // Initialize cache and queue if needed + if (!state.channelMetadataCache[serverId]) { + useStore.setState((state) => ({ + channelMetadataCache: { + ...state.channelMetadataCache, + [serverId]: {}, + }, + })); + } + if (!state.channelMetadataFetchQueue[serverId]) { + useStore.setState((state) => ({ + channelMetadataFetchQueue: { + ...state.channelMetadataFetchQueue, + [serverId]: new Set(), + }, + })); + } + + const cache = state.channelMetadataCache[serverId] || {}; + const queue = state.channelMetadataFetchQueue[serverId] || new Set(); + + // Filter out channels that are already cached or being fetched + const channelsToFetch = channelNames.filter((channelName) => { + const cached = cache[channelName]; + const alreadyQueued = queue.has(channelName); + const isCacheValid = cached && now - cached.fetchedAt < CACHE_TTL; + return !isCacheValid && !alreadyQueued; + }); + + if (channelsToFetch.length === 0) { + return; + } + + // Add to queue + const newQueue = new Set(queue); + for (const ch of channelsToFetch) { + newQueue.add(ch); + } + useStore.setState((state) => ({ + channelMetadataFetchQueue: { + ...state.channelMetadataFetchQueue, + [serverId]: newQueue, + }, + })); + + // Fetch metadata for each channel + // Note: We request metadata even if we're not in the channel + // This may not work on all servers - depends on server permissions + channelsToFetch.forEach((channelName) => { + ircClient.metadataGet(serverId, channelName, ["avatar", "display-name"]); + }); +} + +interface UIState { + selectedServerId: string | null; + // Per-server tab selections - remembers what was selected in each server + perServerSelections: Record< + string, + { + selectedChannelId: string | null; + selectedPrivateChatId: string | null; + } + >; + isDarkMode: boolean; + isMobileMenuOpen: boolean; + isMemberListVisible: boolean; + isChannelListVisible: boolean; + mobileViewActiveColumn: layoutColumn; + isServerMenuOpen: boolean; + contextMenu: { + isOpen: boolean; + x: number; + y: number; + type: "server" | "channel" | "user" | "message"; + itemId: string | null; + }; + prefillServerDetails: ConnectionDetails | null; + inputAttachments: Attachment[]; + // Link security warning modal state - array to support multiple concurrent warnings + linkSecurityWarnings: Array<{ serverId: string; timestamp: number }>; + // Server notices popup state + isServerNoticesPopupOpen: boolean; + serverNoticesPopupMinimized: boolean; + // Profile view request - set when we want to open a user profile after closing settings + profileViewRequest: { serverId: string; username: string } | null; + // Shimmer effect for newly connected servers + serverShimmer?: Set; // Set of server IDs that should show shimmer + // Modal manager state + modals: Record; + modalHistory: string[]; // Stack of open modals for ESC handling +} + +export interface GlobalSettings { + enableNotifications: boolean; + notificationSound: string; + enableNotificationSounds: boolean; + notificationVolume: number; // 0-1, where 0 is muted + enableHighlights: boolean; + sendTypingNotifications: boolean; + // Event visibility settings + showEvents: boolean; + showNickChanges: boolean; + showJoinsParts: boolean; + showQuits: boolean; + showKicks: boolean; + // Custom mentions + customMentions: string[]; + // Ignore list + ignoreList: string[]; + // Hosted chat mode settings + nickname: string; + accountName: string; + accountPassword: string; + // Multiline settings + enableMultilineInput: boolean; + multilineOnShiftEnter: boolean; + autoFallbackToSingleLine: boolean; + // Media settings + showSafeMedia: boolean; + showExternalContent: boolean; + // Markdown settings + enableMarkdownRendering: boolean; +} + +export interface AppState { + servers: Server[]; + currentUser: User | null; + isConnecting: boolean; + selectedServerId: string | null; + connectionError: string | null; + messages: Record; + typingUsers: Record; + typingTimers: Record>; + globalNotifications: { + id: string; + type: "fail" | "warn" | "note"; + command: string; + code: string; + message: string; + target?: string; + serverId: string; + timestamp: Date; + }[]; + channelList: Record< + string, + { channel: string; userCount: number; topic: string }[] + >; // serverId -> channels + channelListBuffer: Record< + string, + { channel: string; userCount: number; topic: string }[] + >; // serverId -> channels (temporary buffer during listing) + channelListFilters: Record< + string, + { + minUsers?: number; + maxUsers?: number; + minCreationTime?: number; // minutes ago + maxCreationTime?: number; // minutes ago + minTopicTime?: number; // minutes ago + maxTopicTime?: number; // minutes ago + mask?: string; + notMask?: string; + } + >; // serverId -> filter settings + listingInProgress: Record; // serverId -> is listing + // Channel metadata cache for /LIST + channelMetadataCache: Record< + string, + Record< + string, + { + avatar?: string; + displayName?: string; + fetchedAt: number; // timestamp + } + > + >; // serverId -> channelName -> metadata + channelMetadataFetchQueue: Record>; // serverId -> Set of channel names being fetched + // Metadata state + metadataSubscriptions: Record; // serverId -> keys + metadataBatches: Record< + string, + { + type: string; + messages: { + target: string; + key: string; + visibility: string; + value: string; + }[]; + } + >; // batchId -> batch info + activeBatches: Record>; // serverId -> batchId -> batch info + metadataFetchInProgress: Record; // serverId -> is fetching own metadata + userMetadataRequested: Record>; // serverId -> Set of usernames we've requested metadata for + metadataChangeCounter: number; // Counter incremented on metadata changes for reactivity + // WHOIS data cache + whoisData: Record>; // serverId -> nickname -> whois data + // Account registration state + pendingRegistration: { + serverId: string; + account: string; + email: string; + password: string; + } | null; + // Channel order persistence + channelOrder: ChannelOrderMap; // serverId -> ordered array of channel names + // Message deduplication tracking + processedMessageIds: Set; // Set of msgid values that have already been processed + // Auto-connect prevention + hasConnectedToSavedServers: boolean; + // UI state + ui: UIState; + globalSettings: GlobalSettings; + // Actions + connect: ( + name: string, + host: string, + port: number, + nickname: string, + saslEnabled: boolean, + password?: string, + saslAccountName?: string, + saslPassword?: string, + registerAccount?: boolean, + registerEmail?: string, + registerPassword?: string, + ) => Promise; + disconnect: (serverId: string) => void; + joinChannel: (serverId: string, channelName: string) => void; + leaveChannel: (serverId: string, channelName: string) => void; + sendMessage: (serverId: string, channelId: string, content: string) => void; + redactMessage: ( + serverId: string, + target: string, + msgid: string, + reason?: string, + ) => void; + registerAccount: ( + serverId: string, + account: string, + email: string, + password: string, + ) => void; + verifyAccount: (serverId: string, account: string, code: string) => void; + warnUser: ( + serverId: string, + channelName: string, + username: string, + reason: string, + ) => void; + kickUser: ( + serverId: string, + channelName: string, + username: string, + reason: string, + ) => void; + banUser: ( + serverId: string, + channelName: string, + username: string, + reason: string, + ) => void; + banUserByNick: ( + serverId: string, + channelName: string, + username: string, + reason: string, + ) => void; + banUserByHostmask: ( + serverId: string, + channelName: string, + username: string, + reason: string, + ) => void; + listChannels: ( + serverId: string, + filters?: { + minUsers?: number; + maxUsers?: number; + minCreationTime?: number; // minutes ago + maxCreationTime?: number; // minutes ago + minTopicTime?: number; // minutes ago + maxTopicTime?: number; // minutes ago + mask?: string; + notMask?: string; + }, + ) => void; + updateChannelListFilters: ( + serverId: string, + filters: { + minUsers?: number; + maxUsers?: number; + minCreationTime?: number; // minutes ago + maxCreationTime?: number; // minutes ago + minTopicTime?: number; // minutes ago + maxTopicTime?: number; // minutes ago + mask?: string; + notMask?: string; + }, + ) => void; + renameChannel: ( + serverId: string, + oldName: string, + newName: string, + reason?: string, + ) => void; + setName: (serverId: string, realname: string) => void; + changeNick: (serverId: string, newNick: string) => void; + addMessage: (message: Message) => void; + addGlobalNotification: (notification: { + type: "fail" | "warn" | "note"; + command: string; + code: string; + message: string; + target?: string; + serverId: string; + }) => void; + removeGlobalNotification: (notificationId: string) => void; + clearGlobalNotifications: () => void; + selectServer: (serverId: string | null) => void; + selectChannel: (channelId: string | null) => void; + selectPrivateChat: (privateChatId: string | null) => void; + openPrivateChat: (serverId: string, username: string) => void; + deletePrivateChat: (serverId: string, privateChatId: string) => void; + pinPrivateChat: (serverId: string, privateChatId: string) => void; + unpinPrivateChat: (serverId: string, privateChatId: string) => void; + reorderPrivateChats: (serverId: string, privateChatIds: string[]) => void; + markChannelAsRead: (serverId: string, channelId: string) => void; + reorderChannels: (serverId: string, channelIds: string[]) => void; + connectToSavedServers: () => void; // New action to load servers from localStorage + reconnectServer: (serverId: string) => Promise; // Reconnect to an existing server + deleteServer: (serverId: string) => void; // New action to delete a server + updateServer: (serverId: string, config: Partial) => void; // Update server configuration + capAck: (serverId: string, key: string, capabilities: string) => void; // Handle CAP ACK + // UI actions + setProfileViewRequest: (serverId: string, username: string) => void; + clearProfileViewRequest: () => void; + toggleDarkMode: () => void; + toggleMobileMenu: (isOpen?: boolean) => void; + toggleMemberList: (isVisible?: boolean) => void; + toggleChannelList: (isOpen?: boolean) => void; + toggleServerMenu: (isOpen?: boolean) => void; + showContextMenu: ( + x: number, + y: number, + type: "server" | "channel" | "user" | "message", + itemId: string, + ) => void; + hideContextMenu: () => void; + setMobileViewActiveColumn: (column: layoutColumn) => void; + // Server notices popup actions + toggleServerNoticesPopup: (isOpen?: boolean) => void; + minimizeServerNoticesPopup: (isMinimized?: boolean) => void; + // Shimmer actions + triggerServerShimmer: (serverId: string) => void; + clearServerShimmer: (serverId: string) => void; + // Settings actions + updateGlobalSettings: (settings: Partial) => void; + // Ignore list actions + addToIgnoreList: (pattern: string) => void; + removeFromIgnoreList: (pattern: string) => void; + // Attachment actions + addInputAttachment: (attachment: Attachment) => void; + removeInputAttachment: (attachmentId: string) => void; + clearInputAttachments: () => void; + // Metadata actions + metadataGet: (serverId: string, target: string, keys: string[]) => void; + metadataList: (serverId: string, target: string) => void; + metadataSet: ( + serverId: string, + target: string, + key: string, + value?: string, + visibility?: string, + ) => void; + metadataClear: (serverId: string, target: string) => void; + metadataSub: (serverId: string, keys: string[]) => void; + metadataUnsub: (serverId: string, keys: string[]) => void; + metadataSubs: (serverId: string) => void; + metadataSync: (serverId: string, target: string) => void; + sendRaw: (serverId: string, command: string) => void; + // Modal manager actions + openModal: (modalId: string, props?: unknown) => void; + closeModal: (modalId: string) => void; + closeTopModal: () => void; + closeAllModals: () => void; + getModalContext: () => { + serverId: string | null; + channelId: string | null; + selectedServer: Server | undefined; + }; +} + +// Helper functions for per-server tab selections +const getServerSelection = (state: AppState, serverId: string) => { + return ( + state.ui.perServerSelections[serverId] || { + selectedChannelId: null, + selectedPrivateChatId: null, + } + ); +}; + +const setServerSelection = ( + state: AppState, + serverId: string, + selection: { + selectedChannelId: string | null; + selectedPrivateChatId: string | null; + }, +) => { + return { + ...state.ui.perServerSelections, + [serverId]: selection, + }; +}; + +const getCurrentSelection = (state: AppState) => { + if (!state.ui.selectedServerId) { + return { + selectedChannelId: null, + selectedPrivateChatId: null, + }; + } + return getServerSelection(state, state.ui.selectedServerId); +}; + +// Create store with Zustand +const useStore = create((set, get) => ({ + servers: [], + currentUser: null, + isConnecting: false, + connectionError: null, + messages: {}, + typingUsers: {}, + typingTimers: {}, + globalNotifications: [], + channelList: {}, + channelListBuffer: {}, + channelListFilters: {}, + listingInProgress: {}, + channelMetadataCache: {}, + channelMetadataFetchQueue: {}, + metadataSubscriptions: {}, + metadataBatches: {}, + activeBatches: {}, + metadataFetchInProgress: {}, + userMetadataRequested: {}, + metadataChangeCounter: 0, + whoisData: {}, + pendingRegistration: null, + channelOrder: loadChannelOrder(), + processedMessageIds: new Set(), + hasConnectedToSavedServers: false, + selectedServerId: null, + + // UI state + ui: { + selectedServerId: null, + perServerSelections: {}, + isDarkMode: true, // Discord-like default is dark mode + isMobileMenuOpen: false, + isMemberListVisible: true, + isChannelListVisible: true, + mobileViewActiveColumn: "serverList", // Default to server list in mobile mode on open + isServerMenuOpen: false, + contextMenu: { + isOpen: false, + x: 0, + y: 0, + type: "server", + itemId: null, + }, + prefillServerDetails: null, + inputAttachments: [], + // Link security warning modal state + linkSecurityWarnings: [], + // Server notices popup state + isServerNoticesPopupOpen: false, + serverNoticesPopupMinimized: false, + // Profile view request + profileViewRequest: null, + // Modal manager state + modals: {}, + modalHistory: [], + }, + globalSettings: { + enableNotifications: false, + notificationSound: "/sounds/notif1.mp3", + enableNotificationSounds: true, + notificationVolume: 0.4, // 40% volume by default + enableHighlights: true, + sendTypingNotifications: true, + // Event visibility settings (enabled by default) + showEvents: true, + showNickChanges: true, + showJoinsParts: true, + showQuits: true, + showKicks: true, + // Custom mentions + customMentions: [], + // Ignore list + ignoreList: ["HistServ!*@*"], + // Hosted chat mode settings + nickname: "", + accountName: "", + accountPassword: "", + // Multiline settings + enableMultilineInput: true, + multilineOnShiftEnter: true, + autoFallbackToSingleLine: true, + // Media settings + showSafeMedia: true, + showExternalContent: false, + // Markdown settings + enableMarkdownRendering: false, + ...loadSavedGlobalSettings(), // Load saved settings from localStorage + }, + + // IRC client actions + connect: async ( + name, + host, + port, + nickname, + _saslEnabled, + password, + saslAccountName, + saslPassword, + registerAccount, + registerEmail, + registerPassword, + ) => { + // Check if already connected to this server + const state = get(); + const existingServer = state.servers.find( + (s) => s.host === host && s.port === port && s.isConnected, + ); + if (existingServer) { + // Already connected, just return the existing server + return existingServer; + } + + set({ isConnecting: true, connectionError: null }); + + try { + // Look up saved server to get its ID + const existingSavedServers: ServerConfig[] = loadSavedServers(); + const existingSavedServer = existingSavedServers.find( + (s) => s.host === host && s.port === port, + ); + + const server = await ircClient.connect( + name, + host, + port, + nickname, + password, + saslAccountName, + saslPassword, + existingSavedServer?.id, // Pass the saved server ID if it exists + ); + + // Save server to localStorage + const savedServers: ServerConfig[] = loadSavedServers(); + const savedServer = savedServers.find( + (s) => s.host === host && s.port === port, + ); + const channelsToJoin = savedServer?.channels || []; + + const updatedServers = savedServers.filter( + (s) => s.host !== host || s.port !== port, + ); + updatedServers.push({ + id: server.id, // Include the server ID here + name: server.name, // Save the server name + host, + port, + nickname, + saslEnabled: !!saslPassword, + password, + channels: channelsToJoin, + saslAccountName, + saslPassword, + // Preserve existing oper credentials and warning preferences + operUsername: savedServer?.operUsername, + operPassword: savedServer?.operPassword, + operOnConnect: savedServer?.operOnConnect, + skipLocalhostWarning: savedServer?.skipLocalhostWarning, + skipLinkSecurityWarning: savedServer?.skipLinkSecurityWarning, + }); + saveServersToLocalStorage(updatedServers); + + set((state) => { + const existingServerIndex = state.servers.findIndex( + (s) => s.host === host && s.port === port, + ); + if (existingServerIndex !== -1) { + // Update existing server properties + const updatedServers = [...state.servers]; + const existingServer = updatedServers[existingServerIndex]; + updatedServers[existingServerIndex] = { + ...existingServer, + ...server, + id: existingServer.id, // Keep the original ID + }; + return { + servers: updatedServers, + isConnecting: false, + }; + } + return { + servers: [...state.servers, server], + isConnecting: false, + }; + }); + + // Check for localhost connection warning (unencrypted ws://) + const isLocalhost = host === "localhost" || host === "127.0.0.1"; + if (isLocalhost) { + const savedServers = loadSavedServers(); + const serverConfig = savedServers.find( + (s) => s.host === host && s.port === port, + ); + + // Only show warning if not already skipped + if (!serverConfig?.skipLocalhostWarning) { + set((state) => ({ + ui: { + ...state.ui, + linkSecurityWarnings: [ + ...state.ui.linkSecurityWarnings, + { serverId: server.id, timestamp: Date.now() }, + ], + }, + })); + } + } + + // Join saved channels - now handled in the ready event handler + // for (const channelName of channelsToJoin) { + // get().joinChannel(server.id, channelName); + // } + + // Set up pending account registration if requested + if (registerAccount && registerEmail && registerPassword) { + set({ + pendingRegistration: { + serverId: server.id, + account: nickname, // Use nickname as account name for now + email: registerEmail, + password: registerPassword, + }, + }); + } + + return server; + } catch (error) { + // Even if connection fails, add the server to the store as disconnected + // so it appears in the UI and can be reconnected later + const disconnectedServer: Server = { + id: uuidv4(), + name: name || host, + host, + port, + channels: [], + privateChats: [], + isConnected: false, + connectionState: "disconnected", + users: [], + }; + + set((state) => { + const existingServerIndex = state.servers.findIndex( + (s) => s.host === host && s.port === port, + ); + if (existingServerIndex !== -1) { + // Update existing server to disconnected + const updatedServers = [...state.servers]; + updatedServers[existingServerIndex] = { + ...updatedServers[existingServerIndex], + isConnected: false, + connectionState: "disconnected", + }; + return { + servers: updatedServers, + isConnecting: false, + connectionError: + error instanceof Error ? error.message : "Unknown error", + }; + } + return { + servers: [...state.servers, disconnectedServer], + isConnecting: false, + connectionError: + error instanceof Error ? error.message : "Unknown error", + }; + }); + + throw error; + } + }, + + disconnect: (serverId) => { + ircClient.disconnect(serverId); + + // Update the state to reflect disconnection + set((state) => { + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + return { + ...server, + isConnected: false, + connectionState: "disconnected" as const, + }; + } + return server; + }); + + // Update selected server/channel if we were on the disconnected server + let newUi = { ...state.ui }; + if (state.ui.selectedServerId === serverId) { + // Find another connected server, or set to null + const nextServer = updatedServers.find( + (s) => s.isConnected && s.id !== serverId, + ); + if (nextServer) { + // Restore the previously selected tab for the new server + const serverSelection = getServerSelection(state, nextServer.id); + newUi = { + ...newUi, + selectedServerId: nextServer.id, + perServerSelections: setServerSelection( + state, + nextServer.id, + serverSelection, + ), + }; + } else { + newUi = { + ...newUi, + selectedServerId: null, + }; + } + } + + return { + servers: updatedServers, + ui: newUi, + }; + }); + }, + + joinChannel: (serverId, channelName) => { + const channel = ircClient.joinChannel(serverId, channelName); + if (channel) { + set((state) => { + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + // Check if channel already exists in store + const existingChannel = server.channels.find( + (c) => c.name.toLowerCase() === channelName.toLowerCase(), + ); + if (existingChannel) { + // Channel already exists, don't add duplicate + return server; + } + return { + ...server, + channels: [...server.channels, channel], + }; + } + return server; + }); + + // Update localStorage with the new channel + const savedServers = loadSavedServers(); + const currentServer = state.servers.find((s) => s.id === serverId); + const savedServer = savedServers.find( + (s) => + s.host === currentServer?.host && s.port === currentServer?.port, + ); + if (savedServer && !savedServer.channels.includes(channel.name)) { + savedServer.channels.push(channel.name); + saveServersToLocalStorage(savedServers); + } + + // Update channelOrder state to include the new channel + const currentOrder = state.channelOrder[serverId] || []; + if (!currentOrder.includes(channel.name)) { + const newChannelOrder = { + ...state.channelOrder, + [serverId]: [...currentOrder, channel.name], + }; + saveChannelOrder(newChannelOrder); + + // Update the selected channel if the server matches the current selection + const isCurrentServer = state.ui.selectedServerId === serverId; + + return { + servers: updatedServers, + channelOrder: newChannelOrder, + ui: { + ...state.ui, + perServerSelections: setServerSelection(state, serverId, { + selectedChannelId: getCurrentSelection(state).selectedChannelId, + selectedPrivateChatId: + getCurrentSelection(state).selectedPrivateChatId, + }), + }, + }; + } + + // Update the selected channel if the server matches the current selection + const isCurrentServer = state.ui.selectedServerId === serverId; + + return { + servers: updatedServers, + ui: { + ...state.ui, + perServerSelections: setServerSelection(state, serverId, { + selectedChannelId: isCurrentServer + ? channel.id + : getCurrentSelection(state).selectedChannelId, + selectedPrivateChatId: + getCurrentSelection(state).selectedPrivateChatId, + }), + }, + }; + }); + } + }, + + leaveChannel: (serverId, channelName) => { + ircClient.leaveChannel(serverId, channelName); // Send PART command to the IRC server + + set((state) => { + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + return { + ...server, + channels: server.channels.filter( + (channel) => channel.name !== channelName, + ), + }; + } + return server; + }); + + // Update localStorage to remove the channel + const savedServers = loadSavedServers(); + const currentServer = updatedServers.find((s) => s.id === serverId); + const savedServer = savedServers.find( + (s) => s.host === currentServer?.host && s.port === currentServer?.port, + ); + if (savedServer) { + savedServer.channels = currentServer?.channels.map((c) => c.name) || []; + saveServersToLocalStorage(savedServers); + } + + // Update channelOrder to remove the channel + const currentOrder = state.channelOrder[serverId] || []; + const newChannelOrder = { + ...state.channelOrder, + [serverId]: currentOrder.filter((name) => name !== channelName), + }; + saveChannelOrder(newChannelOrder); + + return { + servers: updatedServers, + channelOrder: newChannelOrder, + }; + }); + }, + + sendMessage: (serverId, channelId, content) => { + const message = ircClient.sendMessage(serverId, channelId, content); + }, + + redactMessage: ( + serverId: string, + target: string, + msgid: string, + reason?: string, + ) => { + ircClient.sendRedact(serverId, target, msgid, reason); + }, + + registerAccount: ( + serverId: string, + account: string, + email: string, + password: string, + ) => { + ircClient.registerAccount(serverId, account, email, password); + }, + + verifyAccount: (serverId: string, account: string, code: string) => { + ircClient.verifyAccount(serverId, account, code); + }, + + warnUser: (serverId, channelName, username, reason) => { + // Send a warning message to the user + ircClient.sendRaw(serverId, `PRIVMSG ${username} :Warning: ${reason}`); + }, + + kickUser: (serverId, channelName, username, reason) => { + ircClient.sendRaw(serverId, `KICK ${channelName} ${username} :${reason}`); + }, + + banUser: (serverId, channelName, username, reason) => { + // First ban, then kick + ircClient.sendRaw(serverId, `MODE ${channelName} +b ${username}!*@*`); + ircClient.sendRaw(serverId, `KICK ${channelName} ${username} :${reason}`); + }, + + banUserByNick: (serverId, channelName, username, reason) => { + // Ban by nickname only + ircClient.sendRaw(serverId, `MODE ${channelName} +b ${username}`); + ircClient.sendRaw(serverId, `KICK ${channelName} ${username} :${reason}`); + }, + + banUserByHostmask: (serverId, channelName, username, reason) => { + // Ban by hostmask - look up the user's hostname from the channel or server user list + const state = get(); + const server = state.servers.find((s) => s.id === serverId); + if (!server) return; + + const channel = server.channels.find((c) => c.name === channelName); + // Try to find the user in the channel's user list first, then fall back to server user list + const user = + channel?.users.find((u) => u.username === username) || + server.users.find((u) => u.username === username); + + const hostname = user?.hostname || "*"; + ircClient.sendRaw(serverId, `MODE ${channelName} +b *!*@${hostname}`); + ircClient.sendRaw(serverId, `KICK ${channelName} ${username} :${reason}`); + }, + + listChannels: (serverId, filters?) => { + const state = get(); + if (state.listingInProgress[serverId]) { + // Already listing, ignore + return; + } + // Find the server to check for ELIST support + const server = state.servers.find((s) => s.id === serverId); + const elist = server?.elist; + + // Use provided filters or get stored filters + const filterSettings = filters || state.channelListFilters[serverId] || {}; + + // Clear the channel list and buffer before starting a new list + set((state) => ({ + channelList: { + ...state.channelList, + [serverId]: [], + }, + channelListBuffer: { + ...state.channelListBuffer, + [serverId]: [], + }, + listingInProgress: { + ...state.listingInProgress, + [serverId]: true, + }, + })); + ircClient.listChannels(serverId, elist, filterSettings); + }, + + updateChannelListFilters: (serverId, filters) => { + set((state) => ({ + channelListFilters: { + ...state.channelListFilters, + [serverId]: filters, + }, + })); + }, + + renameChannel: (serverId, oldName, newName, reason) => { + ircClient.renameChannel(serverId, oldName, newName, reason); + }, + + setName: (serverId, realname) => { + ircClient.setName(serverId, realname); + }, + + changeNick: (serverId, newNick) => { + ircClient.changeNick(serverId, newNick); + }, + + addMessage: (message) => { + set((state) => { + const channelKey = `${message.serverId}-${message.channelId}`; + const currentMessages = state.messages[channelKey] || []; + + // Check for duplicate messages (same id, or same content/timestamp/user) + const isDuplicate = currentMessages.some((existingMessage) => { + return ( + existingMessage.id === message.id || + (existingMessage.content === message.content && + existingMessage.timestamp === message.timestamp && + existingMessage.userId === message.userId) + ); + }); + + if (isDuplicate) { + return state; // Don't add duplicate message + } + + // Add message and sort chronologically by timestamp + const updatedMessages = [...currentMessages, message].sort((a, b) => { + const timeA = + a.timestamp instanceof Date + ? a.timestamp.getTime() + : new Date(a.timestamp).getTime(); + const timeB = + b.timestamp instanceof Date + ? b.timestamp.getTime() + : new Date(b.timestamp).getTime(); + return timeA - timeB; + }); + + return { + messages: { + ...state.messages, + [channelKey]: updatedMessages, + }, + }; + }); + }, + + addGlobalNotification: (notification) => { + set((state) => ({ + globalNotifications: [ + ...state.globalNotifications, + { + id: uuidv4(), + ...notification, + timestamp: new Date(), + }, + ], + })); + + // Play error sound for FAIL notifications + if (notification.type === "fail") { + try { + const audio = new Audio("/sounds/error.mp3"); + audio.volume = 0.3; // Set reasonable volume for notifications + audio.play().catch((error) => { + console.error("Failed to play error sound:", error); + }); + } catch (error) { + console.error("Failed to play error sound:", error); + } + } + }, + + removeGlobalNotification: (notificationId) => { + set((state) => ({ + globalNotifications: state.globalNotifications.filter( + (n) => n.id !== notificationId, + ), + })); + }, + + clearGlobalNotifications: () => { + set(() => ({ + globalNotifications: [], + })); + }, + + selectServer: (serverId) => { + set((state) => { + // If selecting null (no server), just update the selectedServerId + if (serverId === null) { + return { + ui: { + ...state.ui, + selectedServerId: null, + isMobileMenuOpen: false, + }, + }; + } + + // Find the server + const server = state.servers.find((s) => s.id === serverId); + + // Get the previously selected tab for this server, or default to first channel + const serverSelection = getServerSelection(state, serverId); + let selectedChannelId = serverSelection.selectedChannelId; + let selectedPrivateChatId = serverSelection.selectedPrivateChatId; + + // If no previous selection or the selected items no longer exist, default to first channel + if (server) { + const channelExists = + selectedChannelId && + server.channels.some((c) => c.id === selectedChannelId); + const privateChatExists = + selectedPrivateChatId && + server.privateChats?.some((pc) => pc.id === selectedPrivateChatId); + + if (!channelExists && !privateChatExists) { + selectedChannelId = server.channels[0]?.id || null; + selectedPrivateChatId = null; + } + } + + return { + ui: { + ...state.ui, + selectedServerId: serverId, + perServerSelections: { + ...state.ui.perServerSelections, + [serverId]: { + selectedChannelId, + selectedPrivateChatId, + }, + }, + isMobileMenuOpen: false, + }, + }; + }); + }, + + selectChannel: (channelId) => { + set((state) => { + // Special case for server notices + if (channelId === "server-notices") { + return { + ui: { + ...state.ui, + perServerSelections: setServerSelection( + state, + state.ui.selectedServerId || "", + { + selectedChannelId: channelId, + selectedPrivateChatId: null, + }, + ), + isMobileMenuOpen: false, + mobileViewActiveColumn: "chatView", + }, + }; + } + + // Find which server this channel belongs to + let serverId = state.ui.selectedServerId; + + // If we don't have a server selected or the channel doesn't belong to the selected server + if (!serverId) { + for (const server of state.servers) { + if (server.channels.some((c) => c.id === channelId)) { + serverId = server.id; + break; + } + } + } + + // Mark channel as read + if (serverId && channelId) { + ircClient.markChannelAsRead(serverId, channelId); + + // Update unread state in store + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + const updatedChannels = server.channels.map((channel) => { + if (channel.id === channelId) { + return { + ...channel, + unreadCount: 0, + isMentioned: false, + }; + } + return channel; + }); + + return { + ...server, + channels: updatedChannels, + }; + } + return server; + }); + + return { + servers: updatedServers, + ui: { + ...state.ui, + selectedServerId: serverId, + perServerSelections: setServerSelection(state, serverId, { + selectedChannelId: channelId, + selectedPrivateChatId: null, + }), + isMobileMenuOpen: false, + mobileViewActiveColumn: "chatView", + }, + }; + } + + return { + ui: { + ...state.ui, + perServerSelections: setServerSelection( + state, + state.ui.selectedServerId || "", + { + selectedChannelId: channelId, + selectedPrivateChatId: null, + }, + ), + isMobileMenuOpen: false, + mobileViewActiveColumn: "chatView", + }, + }; + }); + }, + + markChannelAsRead: (serverId, channelId) => { + ircClient.markChannelAsRead(serverId, channelId); + + set((state) => { + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + const updatedChannels = server.channels.map((channel) => { + if (channel.id === channelId) { + return { + ...channel, + unreadCount: 0, + isMentioned: false, + }; + } + return channel; + }); + + return { + ...server, + channels: updatedChannels, + }; + } + return server; + }); + + return { + servers: updatedServers, + }; + }); + }, + + reorderChannels: (serverId, channelIds) => { + set((state) => { + // Also update the savedServer.channels array to match the new order + const server = state.servers.find((s) => s.id === serverId); + if (server) { + const savedServers = loadSavedServers(); + const savedServer = savedServers.find( + (s) => s.host === server.host && s.port === server.port, + ); + + if (savedServer) { + // Convert channel IDs to channel names in the correct order + const channelNames = channelIds + .map((id) => { + const channel = server.channels.find((c) => c.id === id); + return channel?.name; + }) + .filter((name): name is string => name !== undefined); + + savedServer.channels = channelNames; + saveServersToLocalStorage(savedServers); + + // Store channel names in channelOrder state (not IDs) + const newChannelOrder = { + ...state.channelOrder, + [serverId]: channelNames, + }; + + saveChannelOrder(newChannelOrder); + + return { + channelOrder: newChannelOrder, + }; + } + } + + // Fallback if server not found + return {}; + }); + }, + + selectPrivateChat: (privateChatId) => { + set((state) => { + // Find which server this private chat belongs to + let serverId = state.ui.selectedServerId; + + if (!serverId) { + for (const server of state.servers) { + if (server.privateChats?.some((pc) => pc.id === privateChatId)) { + serverId = server.id; + break; + } + } + } + + // If already selected, do nothing + if ( + serverId && + state.ui.perServerSelections[serverId]?.selectedPrivateChatId === + privateChatId + ) { + return state; + } + + // Mark private chat as read + if (serverId && privateChatId) { + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + const updatedPrivateChats = + server.privateChats?.map((privateChat) => { + if (privateChat.id === privateChatId) { + return { + ...privateChat, + unreadCount: 0, + isMentioned: false, + }; + } + return privateChat; + }) || []; + + return { + ...server, + privateChats: updatedPrivateChats, + }; + } + return server; + }); + + return { + servers: updatedServers, + ui: { + ...state.ui, + selectedServerId: serverId, + perServerSelections: setServerSelection(state, serverId, { + selectedChannelId: null, + selectedPrivateChatId: privateChatId, + }), + isMobileMenuOpen: false, + mobileViewActiveColumn: "chatView", + }, + }; + } + + return { + ui: { + ...state.ui, + perServerSelections: setServerSelection( + state, + state.ui.selectedServerId || "", + { + selectedChannelId: null, + selectedPrivateChatId: privateChatId, + }, + ), + isMobileMenuOpen: false, + mobileViewActiveColumn: "chatView", + }, + }; + }); + }, + + openPrivateChat: (serverId, username) => { + set((state) => { + const server = state.servers.find((s) => s.id === serverId); + if (!server) return {}; + + // Get the current user for this specific server + const currentUser = ircClient.getCurrentUser(serverId); + + // Don't allow opening private chats with ourselves + if (currentUser?.username === username) { + return {}; + } + + // Check if private chat already exists + const existingChat = server.privateChats?.find( + (pc) => pc.username === username, + ); + if (existingChat) { + // MONITOR the user if not already monitored + ircClient.monitorAdd(serverId, [username]); + + // Request chathistory for this PM (if server supports it) + if (server.capabilities?.includes("draft/chathistory")) { + setTimeout(() => { + ircClient.sendRaw(serverId, `CHATHISTORY LATEST ${username} * 50`); + }, 50); + } + + // Check if we already have user info from channels + let hasUserInfo = false; + for (const channel of server.channels) { + const user = channel.users.find( + (u) => u.username.toLowerCase() === username.toLowerCase(), + ); + if (user?.realname && user.account !== undefined) { + // We have complete user info, copy it to the PM + hasUserInfo = true; + useStore.setState((state) => ({ + servers: state.servers.map((s) => { + if (s.id === serverId) { + return { + ...s, + privateChats: s.privateChats?.map((pm) => { + if (pm.username === username) { + return { + ...pm, + realname: user.realname, + account: user.account, + isBot: user.isBot, + }; + } + return pm; + }), + }; + } + return s; + }), + })); + break; + } + } + + // Only request WHO if we don't already have complete user info + if (!hasUserInfo) { + // Request WHO to get current status using WHOX to also get account + // Fields: u=username, h=hostname, n=nickname, f=flags, a=account, r=realname + setTimeout(() => { + ircClient.sendRaw(serverId, `WHO ${username} %cuhnfrao`); + }, 100); + } + + // Note: We don't request METADATA GET for individual users as some servers reject this. + // Instead, we rely on metadata from shared channels (if user is in a channel with us) + // or from localStorage if we previously got their metadata. + + // Select existing private chat + return { + ui: { + ...state.ui, + perServerSelections: setServerSelection(state, serverId, { + selectedChannelId: getCurrentSelection(state).selectedChannelId, + selectedPrivateChatId: + getCurrentSelection(state).selectedPrivateChatId, + }), + }, + }; + } + + // Create new private chat + const newPrivateChat: PrivateChat = { + id: uuidv4(), + username, + serverId, + unreadCount: 0, + isMentioned: false, + lastActivity: new Date(), + isOnline: false, // Will be updated by MONITOR response + isAway: false, + }; + + // Check if we already have user info from channels + let hasUserInfo = false; + for (const channel of server.channels) { + const user = channel.users.find( + (u) => u.username.toLowerCase() === username.toLowerCase(), + ); + if (user?.realname && user.account !== undefined) { + // We have complete user info, copy it to the new PM + hasUserInfo = true; + newPrivateChat.realname = user.realname; + newPrivateChat.account = user.account; + newPrivateChat.isBot = user.isBot; + break; + } + } + + const updatedServers = state.servers.map((s) => { + if (s.id === serverId) { + return { + ...s, + privateChats: [...(s.privateChats || []), newPrivateChat], + }; + } + return s; + }); + + // Add MONITOR for this user (server-specific) + ircClient.monitorAdd(serverId, [username]); + + // Request chathistory for this new PM (if server supports it) + if (server.capabilities?.includes("draft/chathistory")) { + setTimeout(() => { + ircClient.sendRaw(serverId, `CHATHISTORY LATEST ${username} * 50`); + }, 50); + } + + // Only request WHO if we don't already have complete user info + if (!hasUserInfo) { + // Request WHO to get their current status (H=here/green, G=gone/yellow) using WHOX to also get account + // Fields: u=username, h=hostname, n=nickname, f=flags, a=account, r=realname + setTimeout(() => { + ircClient.sendRaw(serverId, `WHO ${username} %cuhnfrao`); + }, 100); + } + + // Note: We don't request METADATA GET for individual users as some servers reject this. + // Instead, we rely on metadata from shared channels (if user is in a channel with us) + // or from localStorage if we previously got their metadata. + + return { + servers: updatedServers, + ui: { + ...state.ui, + perServerSelections: setServerSelection(state, serverId, { + selectedChannelId: getCurrentSelection(state).selectedChannelId, + selectedPrivateChatId: + getCurrentSelection(state).selectedPrivateChatId, + }), + }, + }; + }); + }, + + deletePrivateChat: (serverId, privateChatId) => { + set((state) => { + const server = state.servers.find((s) => s.id === serverId); + if (!server) return {}; + + const privateChat = server.privateChats?.find( + (pc) => pc.id === privateChatId, + ); + + const updatedServers = state.servers.map((s) => { + if (s.id === serverId) { + return { + ...s, + privateChats: + s.privateChats?.filter((pc) => pc.id !== privateChatId) || [], + }; + } + return s; + }); + + // If unpinned, remove MONITOR (but don't UNSUB from metadata - that's global) + if (privateChat && !privateChat.isPinned) { + ircClient.monitorRemove(serverId, [privateChat.username]); + } + + // If the deleted private chat was selected, clear the selection + const newState: Partial = { + servers: updatedServers, + }; + + if (getCurrentSelection(state).selectedPrivateChatId === privateChatId) { + newState.ui = { + ...state.ui, + perServerSelections: setServerSelection(state, serverId, { + selectedChannelId: getCurrentSelection(state).selectedChannelId, + selectedPrivateChatId: null, + }), + }; + } + + // Update localStorage if it was pinned + if (privateChat?.isPinned) { + const pinnedChats = loadPinnedPrivateChats(); + if (pinnedChats[serverId]) { + pinnedChats[serverId] = pinnedChats[serverId].filter( + (pc) => pc.username !== privateChat.username, + ); + savePinnedPrivateChats(pinnedChats); + } + } + + return newState; + }); + }, + + pinPrivateChat: (serverId, privateChatId) => { + set((state) => { + const server = state.servers.find((s) => s.id === serverId); + if (!server) return {}; + + const privateChat = server.privateChats?.find( + (pc) => pc.id === privateChatId, + ); + if (!privateChat) return {}; + + // Calculate the new order (highest + 1) + const maxOrder = Math.max( + 0, + ...(server.privateChats + ?.filter((pc) => pc.isPinned && pc.order !== undefined) + .map((pc) => pc.order as number) || []), + ); + + const updatedServers = state.servers.map((s) => { + if (s.id === serverId) { + const updatedPrivateChats = s.privateChats?.map((pc) => { + if (pc.id === privateChatId) { + return { ...pc, isPinned: true, order: maxOrder + 1 }; + } + return pc; + }); + return { ...s, privateChats: updatedPrivateChats }; + } + return s; + }); + + // Save to localStorage + const pinnedChats = loadPinnedPrivateChats(); + if (!pinnedChats[serverId]) { + pinnedChats[serverId] = []; + } + pinnedChats[serverId].push({ + username: privateChat.username, + order: maxOrder + 1, + }); + savePinnedPrivateChats(pinnedChats); + + return { servers: updatedServers }; + }); + }, + + unpinPrivateChat: (serverId, privateChatId) => { + set((state) => { + const server = state.servers.find((s) => s.id === serverId); + if (!server) return {}; + + const privateChat = server.privateChats?.find( + (pc) => pc.id === privateChatId, + ); + if (!privateChat) return {}; + + const updatedServers = state.servers.map((s) => { + if (s.id === serverId) { + const updatedPrivateChats = s.privateChats?.map((pc) => { + if (pc.id === privateChatId) { + return { ...pc, isPinned: false, order: undefined }; + } + return pc; + }); + return { ...s, privateChats: updatedPrivateChats }; + } + return s; + }); + + // Remove from localStorage + const pinnedChats = loadPinnedPrivateChats(); + if (pinnedChats[serverId]) { + pinnedChats[serverId] = pinnedChats[serverId].filter( + (pc) => pc.username !== privateChat.username, + ); + savePinnedPrivateChats(pinnedChats); + } + + return { servers: updatedServers }; + }); + }, + + reorderPrivateChats: (serverId, privateChatIds) => { + set((state) => { + const server = state.servers.find((s) => s.id === serverId); + if (!server) return {}; + + // Update order for each private chat + const updatedServers = state.servers.map((s) => { + if (s.id === serverId) { + const updatedPrivateChats = s.privateChats?.map((pc) => { + const newOrder = privateChatIds.indexOf(pc.id); + if (newOrder !== -1 && pc.isPinned) { + return { ...pc, order: newOrder }; + } + return pc; + }); + return { ...s, privateChats: updatedPrivateChats }; + } + return s; + }); + + // Save to localStorage + const pinnedChats = loadPinnedPrivateChats(); + if (pinnedChats[serverId]) { + // Update order for all pinned chats + pinnedChats[serverId] = pinnedChats[serverId].map((pc) => { + const privateChat = server.privateChats?.find( + (p) => p.username === pc.username, + ); + if (privateChat) { + const newOrder = privateChatIds.indexOf(privateChat.id); + if (newOrder !== -1) { + return { ...pc, order: newOrder }; + } + } + return pc; + }); + savePinnedPrivateChats(pinnedChats); + } + + return { servers: updatedServers }; + }); + }, + + connectToSavedServers: async () => { + const state = get(); + if (state.hasConnectedToSavedServers) { + return; // Already connected, don't do it again + } + + set({ hasConnectedToSavedServers: true }); + + const savedServers = loadSavedServers(); + for (const savedServer of savedServers) { + const { + id, + name, + host, + port, + nickname, + password, + channels, + saslEnabled, + saslAccountName, + saslPassword, + } = savedServer; + + // Check if server already exists in store + const existingServer = get().servers.find( + (s) => s.host === host && s.port === port, + ); + + if (!existingServer) { + // Add server to store with connecting state + const connectingServer: Server = { + id, + name: name || host, + host, + port, + channels: [], + privateChats: [], + isConnected: false, + connectionState: "connecting", + users: [], + }; + + set((state) => ({ + servers: [...state.servers, connectingServer], + })); + } + + try { + await get().connect( + name || host, + host, + port, + nickname, + saslEnabled, + password, + saslAccountName, + saslPassword, + ); + } catch (error) { + console.error(`Failed to reconnect to server ${host}:${port}`, error); + // Update server state to disconnected + set((state) => ({ + servers: state.servers.map((s) => + s.host === host && s.port === port + ? { ...s, connectionState: "disconnected" as const } + : s, + ), + })); + } + } + }, + + reconnectServer: async (serverId: string) => { + const state = get(); + const server = state.servers.find((s) => s.id === serverId); + if (!server) { + console.error(`Server ${serverId} not found`); + return; + } + + // Update server state to connecting + set((state) => ({ + servers: state.servers.map((s) => + s.id === serverId + ? { ...s, connectionState: "connecting" as const } + : s, + ), + })); + + try { + // Get saved server config to get credentials + const savedServers = loadSavedServers(); + const savedServer = savedServers.find( + (s) => s.host === server.host && s.port === server.port, + ); + + if (!savedServer) { + console.error(`No saved configuration found for server ${serverId}`, { + host: server.host, + port: server.port, + savedServers, + }); + throw new Error(`No saved configuration found for server ${serverId}`); + } + + await get().connect( + savedServer.name || savedServer.host, + savedServer.host, + savedServer.port, + savedServer.nickname, + savedServer.saslEnabled, + savedServer.password, + savedServer.saslAccountName, + savedServer.saslPassword, + ); + } catch (error) { + console.error(`Failed to reconnect to server ${serverId}`, error); + // Update server state back to disconnected + set((state) => ({ + servers: state.servers.map((s) => + s.id === serverId + ? { ...s, connectionState: "disconnected" as const } + : s, + ), + })); + } + }, + + deleteServer: (serverId) => { + set((state) => { + const serverToDelete = state.servers.find( + (server) => server.id === serverId, + ); + + // Remove server from localStorage + const savedServers = loadSavedServers(); + const updatedServers = savedServers.filter( + (s) => + s.host !== serverToDelete?.host || s.port !== serverToDelete?.port, + ); + saveServersToLocalStorage(updatedServers); + + // Remove server's metadata from localStorage + const savedMetadata = loadSavedMetadata(); + delete savedMetadata[serverId]; + saveMetadataToLocalStorage(savedMetadata); + + // Update state + const remainingServers = state.servers.filter( + (server) => server.id !== serverId, + ); + const newSelectedServerId = + remainingServers.length > 0 ? remainingServers[0].id : null; + + return { + servers: remainingServers, + ui: { + ...state.ui, + selectedServerId: newSelectedServerId, + selectedChannelId: newSelectedServerId + ? remainingServers[0].channels[0]?.id || null + : null, + }, + }; + }); + + ircClient.disconnect(serverId); + }, + + updateServer: (serverId, config) => { + const savedServers = loadSavedServers(); + const serverIndex = savedServers.findIndex((s) => s.id === serverId); + + if (serverIndex !== -1) { + savedServers[serverIndex] = { ...savedServers[serverIndex], ...config }; + saveServersToLocalStorage(savedServers); + } + }, + + // UI actions + setProfileViewRequest: (serverId, username) => { + set((state) => ({ + ui: { + ...state.ui, + profileViewRequest: { serverId, username }, + }, + })); + }, + + clearProfileViewRequest: () => { + set((state) => ({ + ui: { + ...state.ui, + profileViewRequest: null, + }, + })); + }, + + toggleDarkMode: () => { + set((state) => ({ + ui: { + ...state.ui, + isDarkMode: !state.ui.isDarkMode, + }, + })); + }, + + toggleMobileMenu: (isOpen) => { + set((state) => ({ + ui: { + ...state.ui, + isMobileMenuOpen: + isOpen !== undefined ? isOpen : !state.ui.isMobileMenuOpen, + }, + })); + }, + + toggleMemberList: (isOpen) => { + set((state) => { + const openState = + isOpen !== undefined ? isOpen : !state.ui.isChannelListVisible; + + // Only change mobileViewActiveColumn if we're not on the serverList view + // This prevents desktop member list toggles from affecting mobile navigation + const shouldUpdateMobileColumn = + state.ui.mobileViewActiveColumn !== "serverList"; + + return { + ui: { + ...state.ui, + isMemberListVisible: + openState !== undefined ? openState : !state.ui.isMemberListVisible, + mobileViewActiveColumn: shouldUpdateMobileColumn + ? openState + ? "memberList" + : "chatView" + : state.ui.mobileViewActiveColumn, + }, + }; + }); + }, + + toggleChannelList: (isOpen) => { + set((state) => { + const openState = + isOpen !== undefined ? isOpen : !state.ui.isChannelListVisible; + return { + ui: { + ...state.ui, + isChannelListVisible: openState, + mobileViewActiveColumn: openState + ? "serverList" + : state.ui.mobileViewActiveColumn, + }, + }; + }); + }, + + toggleServerMenu: (isOpen) => { + set((state) => ({ + ui: { + ...state.ui, + isServerMenuOpen: + isOpen !== undefined ? isOpen : !state.ui.isServerMenuOpen, + }, + })); + }, + + showContextMenu: (x, y, type, itemId) => { + set((state) => ({ + ui: { + ...state.ui, + contextMenu: { + isOpen: true, + x, + y, + type, + itemId, + }, + }, + })); + }, + + hideContextMenu: () => { + set((state) => ({ + ui: { + ...state.ui, + contextMenu: { + ...state.ui.contextMenu, + isOpen: false, + }, + }, + })); + }, + + setMobileViewActiveColumn: (column: layoutColumn) => { + set((state) => ({ + ui: { + ...state.ui, + mobileViewActiveColumn: column, + }, + })); + }, + + toggleServerNoticesPopup: (isOpen) => { + set((state) => ({ + ui: { + ...state.ui, + isServerNoticesPopupOpen: + isOpen !== undefined ? isOpen : !state.ui.isServerNoticesPopupOpen, + serverNoticesPopupMinimized: false, // Reset minimized state when toggling + }, + })); + }, + + minimizeServerNoticesPopup: (isMinimized) => { + set((state) => ({ + ui: { + ...state.ui, + serverNoticesPopupMinimized: + isMinimized !== undefined + ? isMinimized + : !state.ui.serverNoticesPopupMinimized, + }, + })); + }, + + triggerServerShimmer: (serverId) => { + set((state) => { + const newShimmer = new Set(state.ui.serverShimmer); + newShimmer.add(serverId); + return { + ui: { + ...state.ui, + serverShimmer: newShimmer, + }, + }; + }); + // Clear shimmer after animation duration (e.g., 2 seconds) + setTimeout(() => { + get().clearServerShimmer(serverId); + }, 2000); + }, + + clearServerShimmer: (serverId) => { + set((state) => { + const newShimmer = new Set(state.ui.serverShimmer); + newShimmer.delete(serverId); + return { + ui: { + ...state.ui, + serverShimmer: newShimmer, + }, + }; + }); + }, + + // Modal manager actions + openModal: (modalId, props) => { + set((state) => ({ + ui: { + ...state.ui, + modals: { + ...state.ui.modals, + [modalId]: { isOpen: true, props }, + }, + modalHistory: [...state.ui.modalHistory, modalId], + // Handle addServer modal's prefillServerDetails + ...(modalId === "addServer" + ? { + prefillServerDetails: props ? (props as ConnectionDetails) : null, + } + : {}), + }, + })); + }, + + closeModal: (modalId) => { + set((state) => ({ + ui: { + ...state.ui, + modals: { + ...state.ui.modals, + [modalId]: { isOpen: false, props: undefined }, + }, + modalHistory: state.ui.modalHistory.filter((id) => id !== modalId), + // Clear prefillServerDetails when closing addServer modal + ...(modalId === "addServer" ? { prefillServerDetails: null } : {}), + }, + })); + }, + + closeTopModal: () => { + const state = get(); + const topModalId = state.ui.modalHistory[state.ui.modalHistory.length - 1]; + if (topModalId) { + get().closeModal(topModalId); + } + }, + + closeAllModals: () => { + set((state) => ({ + ui: { + ...state.ui, + modals: {}, + modalHistory: [], + prefillServerDetails: null, + }, + })); + }, + + getModalContext: () => { + const state = get(); + const serverId = state.ui.selectedServerId; + const selectedServer = state.servers.find((s) => s.id === serverId); + + const selection = serverId + ? getServerSelection(state, serverId) + : { selectedChannelId: null, selectedPrivateChatId: null }; + + const channelId = selection.selectedChannelId; + + return { + serverId, + channelId, + selectedServer, + }; + }, + + // Settings actions + updateGlobalSettings: (settings: Partial) => { + set((state) => { + const newGlobalSettings = { + ...state.globalSettings, + ...settings, + }; + // Save to localStorage + saveGlobalSettingsToLocalStorage(newGlobalSettings); + return { + globalSettings: newGlobalSettings, + }; + }); + }, + + // Ignore list actions + addToIgnoreList: (pattern: string) => { + set((state) => { + const trimmedPattern = pattern.trim(); + if ( + !trimmedPattern || + state.globalSettings.ignoreList.includes(trimmedPattern) + ) { + return state; + } + + const newIgnoreList = [ + ...state.globalSettings.ignoreList, + trimmedPattern, + ]; + const newGlobalSettings = { + ...state.globalSettings, + ignoreList: newIgnoreList, + }; + + // Save to localStorage + saveGlobalSettingsToLocalStorage(newGlobalSettings); + + return { + globalSettings: newGlobalSettings, + }; + }); + }, + + removeFromIgnoreList: (pattern: string) => { + set((state) => { + const newIgnoreList = state.globalSettings.ignoreList.filter( + (p) => p !== pattern, + ); + const newGlobalSettings = { + ...state.globalSettings, + ignoreList: newIgnoreList, + }; + + // Save to localStorage + saveGlobalSettingsToLocalStorage(newGlobalSettings); + + return { + globalSettings: newGlobalSettings, + }; + }); + }, + + // Attachment actions + addInputAttachment: (attachment: Attachment) => { + set((state) => ({ + ui: { + ...state.ui, + inputAttachments: [...state.ui.inputAttachments, attachment], + }, + })); + }, + + removeInputAttachment: (attachmentId: string) => { + set((state) => ({ + ui: { + ...state.ui, + inputAttachments: state.ui.inputAttachments.filter( + (att) => att.id !== attachmentId, + ), + }, + })); + }, + + clearInputAttachments: () => { + set((state) => ({ + ui: { + ...state.ui, + inputAttachments: [], + }, + })); + }, + + // Metadata actions + metadataGet: (serverId, target, keys) => { + if (serverSupportsMetadata(serverId)) { + ircClient.metadataGet(serverId, target, keys); + } + }, + + metadataList: (serverId, target) => { + if (!serverSupportsMetadata(serverId)) { + return; + } + + // Check if we've already requested metadata for this user + const requestedUsers = get().userMetadataRequested[serverId] || new Set(); + if (requestedUsers.has(target)) { + return; // Already requested + } + + // Check if we already have metadata for this user + const savedMetadata = loadSavedMetadata(); + const serverMetadata = savedMetadata[serverId]; + if ( + serverMetadata?.[target] && + Object.keys(serverMetadata[target]).length > 0 + ) { + // We already have metadata, mark as requested to avoid future requests + set((state) => ({ + userMetadataRequested: { + ...state.userMetadataRequested, + [serverId]: new Set([ + ...(state.userMetadataRequested[serverId] || []), + target, + ]), + }, + })); + return; // No need to request + } + + // Check if user is in any channel and has metadata there + const server = get().servers.find((s) => s.id === serverId); + if (server) { + for (const channel of server.channels) { + const user = channel.users.find( + (u) => u.username.toLowerCase() === target.toLowerCase(), + ); + if (user?.metadata && Object.keys(user.metadata).length > 0) { + // We already have metadata, mark as requested + set((state) => ({ + userMetadataRequested: { + ...state.userMetadataRequested, + [serverId]: new Set([ + ...(state.userMetadataRequested[serverId] || []), + target, + ]), + }, + })); + return; // No need to request + } + } + } + + // Mark as requested and fetch metadata + set((state) => ({ + userMetadataRequested: { + ...state.userMetadataRequested, + [serverId]: new Set([ + ...(state.userMetadataRequested[serverId] || []), + target, + ]), + }, + })); + + ircClient.metadataList(serverId, target); + }, + + metadataSet: (serverId, target, key, value, visibility) => { + if (serverSupportsMetadata(serverId)) { + ircClient.metadataSet(serverId, target, key, value, visibility); + } + }, + + metadataClear: (serverId, target) => { + if (serverSupportsMetadata(serverId)) { + ircClient.metadataClear(serverId, target); + } + }, + + metadataSub: (serverId, keys) => { + if (serverSupportsMetadata(serverId)) { + console.log( + `[METADATA_SUB] Subscribing to keys for server ${serverId}:`, + keys, + ); + ircClient.metadataSub(serverId, keys); + } else { + } + }, + + metadataUnsub: (serverId, keys) => { + if (serverSupportsMetadata(serverId)) { + ircClient.metadataUnsub(serverId, keys); + } + }, + + metadataSubs: (serverId) => { + if (serverSupportsMetadata(serverId)) { + ircClient.metadataSubs(serverId); + } + }, + + metadataSync: (serverId, target) => { + if (serverSupportsMetadata(serverId)) { + ircClient.metadataSync(serverId, target); + } + }, + + sendRaw: (serverId, command) => { + ircClient.sendRaw(serverId, command); + }, + + capAck: (serverId, key, capabilities) => { + ircClient.capAck(serverId, key, capabilities); + }, +})); + +// Initialize protocol handlers +registerAllProtocolHandlers(ircClient, useStore); + +// Set up event listeners for IRC client events +// +// TODO: We should have actual events here, The commended ones are never fired and seems to be causing a bug with the state management +// ircClient.on( +// "message", +// (response: { serverId: string; channelId: string; message: Message }) => { +// const { serverId, channelId, message } = response; +// useStore.getState().addMessage(message); +// }, +// ); + +// ircClient.on("system_message", (response: { message: Message }) => { +// const { message } = response; +// useStore.getState().addMessage(message); +// }); + +// ircClient.on("connect", (response: { servers: Server[] }) => { +// const { servers } = response; +// useStore.setState({ servers }); +// }); + +// ircClient.on("disconnect", (response: { serverId: string }) => { +// const { serverId } = response; +// if (serverId) { +// // Update specific server status +// useStore.setState((state) => ({ +// servers: state.servers.map((server) => +// server.id === serverId ? { ...server, isConnected: false } : server, +// ), +// })); +// } else { +// // Refresh servers list +// const servers = ircClient.getServers(); +// useStore.setState({ servers }); +// } +// }); + +ircClient.on("connectionStateChange", ({ serverId, connectionState }) => { + useStore.setState((state) => { + const updatedServers = state.servers.map((server) => + server.id === serverId + ? { + ...server, + connectionState, + isConnected: connectionState === "connected", + } + : server, + ); + + // If a server just connected and we have no selected server (showing welcome screen), + // switch back to this server to maintain continuity during reconnection + let newUi = { ...state.ui }; + if (connectionState === "connected" && state.ui.selectedServerId === null) { + const reconnectedServer = updatedServers.find((s) => s.id === serverId); + if (reconnectedServer) { + const serverSelection = getServerSelection(state, serverId); + newUi = { + ...newUi, + selectedServerId: serverId, + perServerSelections: setServerSelection( + state, + serverId, + serverSelection, + ), + }; + } + } + + return { + servers: updatedServers, + ui: newUi, + }; + }); +}); + +ircClient.on("CHANMSG", (response) => { + const { mtags, channelName, message, timestamp } = response; + + // Check for duplicate messages based on msgid + if (mtags?.msgid) { + const currentState = useStore.getState(); + if (currentState.processedMessageIds.has(mtags.msgid)) { + console.log(`Skipping duplicate message with msgid: ${mtags.msgid}`); + return; + } + } + + // Check if sender is ignored + const globalSettings = useStore.getState().globalSettings; + if ( + isUserIgnored( + response.sender, + undefined, + undefined, + globalSettings.ignoreList, + ) + ) { + // User is ignored, skip processing this message + return; + } + + // Find the server and channel + const server = useStore + .getState() + .servers.find((s) => s.id === response.serverId); + + if (server) { + const channel = server.channels.find( + (c) => c.name.toLowerCase() === channelName.toLowerCase(), + ); + const replyTo = null; + + if (channel) { + const replyId = mtags?.["+draft/reply"] + ? mtags["+draft/reply"].trim() + : null; + + const replyMessage = replyId + ? findChannelMessageById(server.id, channel.id, replyId) || null + : null; + + // Check for mentions and get current state + const currentState = useStore.getState(); + const currentServerUser = ircClient.getCurrentUser(response.serverId); + // Don't trigger mentions for our own messages + const isOwnMessage = response.sender === currentServerUser?.username; + const hasMention = + !isOwnMessage && + checkForMention( + message, + currentServerUser, + currentState.globalSettings, + ); + const mentions = !isOwnMessage + ? extractMentions( + message, + currentServerUser, + currentState.globalSettings, + ) + : []; + + const newMessage = { + id: uuidv4(), + msgid: mtags?.msgid, + content: message, + timestamp, + userId: response.sender, + channelId: channel.id, + serverId: server.id, + type: "message" as const, + reactions: [], + replyMessage: replyMessage, + mentioned: mentions, + tags: mtags, + }; + + // Update channel unread count and mention flag if not the active channel + const isActiveChannel = + getCurrentSelection(currentState).selectedChannelId === channel.id && + currentState.ui.selectedServerId === server.id; + + // Don't count unread/mentions for historical messages (batch tag indicates chathistory playback) + const isHistoricalMessage = mtags?.batch !== undefined; + + if ( + !isActiveChannel && + response.sender !== currentServerUser?.username && + !isHistoricalMessage + ) { + useStore.setState((state) => { + const updatedServers = state.servers.map((s) => { + if (s.id === server.id) { + const updatedChannels = s.channels.map((ch) => { + if (ch.id === channel.id) { + return { + ...ch, + unreadCount: ch.unreadCount + 1, + isMentioned: hasMention || ch.isMentioned, + }; + } + return ch; + }); + return { ...s, channels: updatedChannels }; + } + return s; + }); + return { servers: updatedServers }; + }); + + // Show browser notification for mentions + if (hasMention && currentState.globalSettings.enableNotifications) { + showMentionNotification( + server.id, + channelName, + response.sender, + message, + (serverId, msg) => { + // Fallback: Add a NOTE standard reply notification + useStore.getState().addGlobalNotification({ + type: "note", + command: "MENTION", + code: "HIGHLIGHT", + message: msg, + serverId, + }); + }, + ); + } + } + + // If message has bot tag, mark user as bot + if (mtags?.bot !== undefined) { + useStore.setState((state) => { + const updatedServers = state.servers.map((s) => { + if (s.id === server.id) { + const updatedChannels = s.channels.map((channel) => { + const updatedUsers = channel.users.map((user) => { + if (user.username === response.sender) { + return { + ...user, + isBot: true, // Set bot flag from message tags + metadata: { + ...user.metadata, + bot: { value: "true", visibility: "public" }, + }, + }; + } + return user; + }); + return { ...channel, users: updatedUsers }; + }); + return { ...s, channels: updatedChannels }; + } + return s; + }); + return { servers: updatedServers }; + }); + } + + useStore.getState().addMessage(newMessage); + + // Play notification sound if appropriate (but not for historical messages) + if (!isHistoricalMessage) { + const state = useStore.getState(); + const serverCurrentUser = ircClient.getCurrentUser(response.serverId); + if ( + shouldPlayNotificationSound( + newMessage, + serverCurrentUser, + state.globalSettings, + ) + ) { + playNotificationSound(state.globalSettings); + } + } + + // Mark this message ID as processed to prevent duplicates + if (mtags?.msgid) { + useStore.setState((state) => ({ + processedMessageIds: new Set([ + ...state.processedMessageIds, + mtags.msgid, + ]), + })); + } + + // Remove any typing users from the state + useStore.setState((state) => { + const key = `${server.id}-${channel.id}`; + const currentUsers = state.typingUsers[key] || []; + return { + typingUsers: { + ...state.typingUsers, + [key]: currentUsers.filter((u) => u.username !== response.sender), + }, + }; + }); + } + } +}); + +// Handle multiline messages +ircClient.on("MULTILINE_MESSAGE", (response) => { + const { mtags, channelName, sender, message, messageIds, timestamp } = + response; + + // Check for duplicate messages based on messageIds + if (messageIds && messageIds.length > 0) { + const currentState = useStore.getState(); + const hasDuplicate = messageIds.some((id) => + currentState.processedMessageIds.has(id), + ); + if (hasDuplicate) { + console.log( + `Skipping duplicate multiline message with messageIds: ${messageIds.join(", ")}`, + ); + return; + } + } + + // Check if sender is ignored + const globalSettings = useStore.getState().globalSettings; + if (isUserIgnored(sender, undefined, undefined, globalSettings.ignoreList)) { + // User is ignored, skip processing this message + return; + } + + // Find the server and channel + const server = useStore + .getState() + .servers.find((s) => s.id === response.serverId); + + if (server) { + const channel = channelName + ? server.channels.find( + (c) => c.name.toLowerCase() === channelName.toLowerCase(), + ) + : null; + + if (channel) { + const replyId = mtags?.["+draft/reply"] + ? mtags["+draft/reply"].trim() + : null; + + const replyMessage = replyId + ? findChannelMessageById(server.id, channel.id, replyId) || null + : null; + + const newMessage = { + id: uuidv4(), + msgid: mtags?.msgid, + multilineMessageIds: messageIds, // Store all message IDs for redaction + content: message, // Use the properly combined message from IRC client + timestamp, + userId: sender, + channelId: channel.id, + serverId: server.id, + type: "message" as const, + reactions: [], + replyMessage: replyMessage, + mentioned: [], // Add logic for mentions if needed + tags: mtags, + }; + + // If message has bot tag, mark user as bot + if (mtags?.bot !== undefined) { + useStore.setState((state) => { + const updatedServers = state.servers.map((s) => { + if (s.id === server.id) { + const updatedChannels = s.channels.map((channel) => { + const updatedUsers = channel.users.map((user) => { + if (user.username === sender) { + return { + ...user, + isBot: true, + }; + } + return user; + }); + return { ...channel, users: updatedUsers }; + }); + return { ...s, channels: updatedChannels }; + } + return s; + }); + return { servers: updatedServers }; + }); + } + + // Mark these message IDs as processed to prevent duplicates + if (messageIds && messageIds.length > 0) { + useStore.setState((state) => ({ + processedMessageIds: new Set([ + ...state.processedMessageIds, + ...messageIds, + ]), + })); + } + + useStore.getState().addMessage(newMessage); + + // Play notification sound if appropriate (but not for historical messages) + // Don't count unread/mentions for historical messages (batch tag indicates chathistory playback) + const isHistoricalMessage = mtags?.batch !== undefined; + + if (!isHistoricalMessage) { + const state = useStore.getState(); + const serverCurrentUser = ircClient.getCurrentUser(response.serverId); + if ( + shouldPlayNotificationSound( + newMessage, + serverCurrentUser, + state.globalSettings, + ) + ) { + playNotificationSound(state.globalSettings); + } + } + + // Remove any typing users from the state + useStore.setState((state) => { + const key = `${server.id}-${channel.id}`; + const currentUsers = state.typingUsers[key] || []; + return { + typingUsers: { + ...state.typingUsers, + [key]: currentUsers.filter((u) => u.username !== sender), + }, + }; + }); + } else if (!channelName) { + // Handle multiline private messages + // Similar logic to USERMSG but for multiline content + const currentUser = ircClient.getCurrentUser(response.serverId); + if (currentUser && sender === currentUser.username) { + return; // Don't create private chats with ourselves + } + + // Create or find private chat + let privateChat = server.privateChats.find( + (chat) => chat.username === sender, + ); + if (!privateChat) { + const newPrivateChat = { + id: uuidv4(), + username: sender, + serverId: server.id, + unreadCount: 0, + isMentioned: false, + lastActivity: new Date(), + isPinned: false, + order: undefined, + isOnline: false, // Will be updated by MONITOR + isAway: false, + }; + privateChat = newPrivateChat; + useStore.setState((state) => ({ + servers: state.servers.map((s) => + s.id === server.id + ? { ...s, privateChats: [...s.privateChats, newPrivateChat] } + : s, + ), + })); + } + + const newMessage = { + id: uuidv4(), + msgid: mtags?.msgid, + multilineMessageIds: messageIds, // Store all message IDs for redaction + content: message, // Use the properly combined message from IRC client + timestamp, + userId: sender, + channelId: privateChat.id, + serverId: server.id, + type: "message" as const, + reactions: [], + replyMessage: null, + mentioned: [], + tags: mtags, + }; + + useStore.getState().addMessage(newMessage); + + // Play notification sound if appropriate (but not for historical messages) + // Don't count unread/mentions for historical messages (batch tag indicates chathistory playback) + const isHistoricalMessage = mtags?.batch !== undefined; + + if (!isHistoricalMessage) { + const state = useStore.getState(); + const serverCurrentUser = ircClient.getCurrentUser(response.serverId); + if ( + shouldPlayNotificationSound( + newMessage, + serverCurrentUser, + state.globalSettings, + ) + ) { + playNotificationSound(state.globalSettings); + } + } + } + } +}); + +// Handle private messages (USERMSG) +ircClient.on("USERMSG", (response) => { + const { mtags, sender, target, message, timestamp } = response; + + console.log("[USERMSG] Received:", { + sender, + target, + message, + channelContext: mtags?.["+draft/channel-context"], + }); + + // Check for duplicate messages based on msgid + if (mtags?.msgid) { + const currentState = useStore.getState(); + if (currentState.processedMessageIds.has(mtags.msgid)) { + console.log(`Skipping duplicate USERMSG with msgid: ${mtags.msgid}`); + return; + } + } + + // Find the server + const server = useStore + .getState() + .servers.find((s) => s.id === response.serverId); + + if (server) { + // Check if this PRIVMSG is from the server itself (sender contains a ".") + // Server messages should go to Server Notices, not create PM tabs + if (sender.includes(".")) { + console.log( + "[USERMSG] Server message detected, routing to Server Notices:", + sender, + ); + + const targetChannelId = "server-notices"; + const newMessage: Message = { + id: uuidv4(), + type: "notice", + content: message, + timestamp: timestamp, + userId: sender, + channelId: targetChannelId, + serverId: server.id, + reactions: [], + replyMessage: null, + mentioned: [], + tags: mtags, + }; + + useStore.getState().addMessage(newMessage); + + // Play notification sound if appropriate (but not for historical messages) + const isHistoricalMessage = mtags?.batch !== undefined; + if (!isHistoricalMessage) { + const state = useStore.getState(); + const serverCurrentUser = ircClient.getCurrentUser(response.serverId); + if ( + shouldPlayNotificationSound( + newMessage, + serverCurrentUser, + state.globalSettings, + ) + ) { + playNotificationSound(state.globalSettings); + } + } + + return; // Don't process as a regular PM + } + + // Check if this is a whisper (has draft/channel-context tag) + // Note: Client tags use + prefix, so check both with and without + const channelContext = mtags?.["+draft/channel-context"]; + + if (channelContext) { + console.log("[WHISPER] Detected channel-context tag:", channelContext); + console.log( + "[WHISPER] Available channels:", + server.channels.map((c) => c.name), + ); + + // This is a whisper - route it to the channel specified in the tag + // Use case-insensitive matching + const channel = server.channels.find( + (c) => c.name.toLowerCase() === channelContext.toLowerCase(), + ); + + console.log( + "[WHISPER] Found channel:", + channel ? channel.name : "NOT FOUND", + ); + + if (channel) { + const replyId = mtags?.["+draft/reply"] + ? mtags["+draft/reply"].trim() + : null; + + const replyMessage = replyId + ? findChannelMessageById(server.id, channel.id, replyId) || null + : null; + + const newMessage = { + id: uuidv4(), + msgid: mtags?.msgid, + content: message, + timestamp, + userId: sender, + channelId: channel.id, + serverId: server.id, + type: "message" as const, + reactions: [], + replyMessage: replyMessage, + mentioned: [], + tags: mtags, // This includes the draft/channel-context tag + whisperTarget: target, // Store the recipient for display + }; + + // Mark this message ID as processed to prevent duplicates + if (mtags?.msgid) { + useStore.setState((state) => ({ + processedMessageIds: new Set([ + ...state.processedMessageIds, + mtags.msgid, + ]), + })); + } + + useStore.getState().addMessage(newMessage); + + // Play notification sound if appropriate (only if it's not from ourselves and not historical) + const currentUser = ircClient.getCurrentUser(response.serverId); + const isHistoricalMessage = mtags?.batch !== undefined; + + if (currentUser?.username !== sender && !isHistoricalMessage) { + const state = useStore.getState(); + const serverCurrentUser = ircClient.getCurrentUser(response.serverId); + if ( + shouldPlayNotificationSound( + newMessage, + serverCurrentUser, + state.globalSettings, + ) + ) { + playNotificationSound(state.globalSettings); + } + } + + return; // Early return - don't create a private chat + } + } + } + + // Don't create private chats with ourselves when the server echoes back our own messages + const currentUser = ircClient.getCurrentUser(response.serverId); + if (currentUser?.username === sender) { + return; + } + + // Check if sender is ignored + const globalSettings = useStore.getState().globalSettings; + if (isUserIgnored(sender, undefined, undefined, globalSettings.ignoreList)) { + // User is ignored, skip processing this message + return; + } + + if (server) { + // Find or create private chat + let privateChat = server.privateChats?.find((pc) => pc.username === sender); + + if (!privateChat) { + // Auto-create private chat when receiving a message + useStore.getState().openPrivateChat(server.id, sender); + // Get the newly created private chat + privateChat = useStore + .getState() + .servers.find((s) => s.id === server.id) + ?.privateChats?.find((pc) => pc.username === sender); + } + + if (privateChat) { + const newMessage = { + id: uuidv4(), + msgid: mtags?.msgid, + content: message, + timestamp, + userId: sender, + channelId: privateChat.id, // Use private chat ID as channel ID + serverId: server.id, + type: "message" as const, + reactions: [], + replyMessage: null, + mentioned: [], // PMs don't have mentions in the traditional sense + tags: mtags, + }; + + // If message has bot tag, mark user as bot + if (mtags?.bot !== undefined) { + useStore.setState((state) => { + const updatedServers = state.servers.map((s) => { + if (s.id === server.id) { + const updatedChannels = s.channels.map((channel) => { + const updatedUsers = channel.users.map((user) => { + if (user.username === sender) { + return { + ...user, + isBot: true, // Set bot flag from message tags + metadata: { + ...user.metadata, + bot: { value: "true", visibility: "public" }, + }, + }; + } + return user; + }); + return { ...channel, users: updatedUsers }; + }); + return { ...s, channels: updatedChannels }; + } + return s; + }); + return { servers: updatedServers }; + }); + } + + // Mark this message ID as processed to prevent duplicates + if (mtags?.msgid) { + useStore.setState((state) => ({ + processedMessageIds: new Set([ + ...state.processedMessageIds, + mtags.msgid, + ]), + })); + } + + useStore.getState().addMessage(newMessage); + + // Remove any typing users from the state + useStore.setState((state) => { + const key = `${server.id}-${privateChat.id}`; + const currentUsers = state.typingUsers[key] || []; + return { + typingUsers: { + ...state.typingUsers, + [key]: currentUsers.filter((u) => u.username !== sender), + }, + }; + }); + + // Update private chat's last activity and unread count + // Don't count unread/mentions for historical messages (batch tag indicates chathistory playback) + const isHistoricalMessage = mtags?.batch !== undefined; + + // Play notification sound if appropriate (but not for historical messages) + if (!isHistoricalMessage) { + const state = useStore.getState(); + const serverCurrentUser = ircClient.getCurrentUser(response.serverId); + if ( + shouldPlayNotificationSound( + newMessage, + serverCurrentUser, + state.globalSettings, + ) + ) { + playNotificationSound(state.globalSettings); + } + } + + useStore.setState((state) => { + const updatedServers = state.servers.map((s) => { + if (s.id === response.serverId) { + const updatedPrivateChats = + s.privateChats?.map((pc) => { + if (pc.id === privateChat.id) { + const isActive = + getCurrentSelection(state).selectedPrivateChatId === pc.id; + return { + ...pc, + lastActivity: new Date(), + unreadCount: + isActive || isHistoricalMessage ? 0 : pc.unreadCount + 1, + isMentioned: !isHistoricalMessage && true, // All PMs are considered mentions (except historical) + }; + } + return pc; + }) || []; + return { ...s, privateChats: updatedPrivateChats }; + } + return s; + }); + return { servers: updatedServers }; + }); + + // Show browser notification for private messages + const currentState = useStore.getState(); + const isActiveChat = + getCurrentSelection(currentState).selectedPrivateChatId === + privateChat.id; + if ( + !isActiveChat && + !isHistoricalMessage && + currentState.globalSettings.enableNotifications + ) { + showMentionNotification( + server.id, + `DM from ${sender}`, + sender, + message, + (serverId, msg) => { + // Fallback: Add a NOTE standard reply notification + useStore.getState().addGlobalNotification({ + type: "note", + command: "PRIVMSG", + code: "DM", + message: msg, + serverId, + }); + }, + ); + } + } + } +}); + +ircClient.on("CHANNNOTICE", (response) => { + const { mtags, channelName, message, timestamp } = response; + + // Check for duplicate messages based on msgid + if (mtags?.msgid) { + const currentState = useStore.getState(); + if (currentState.processedMessageIds.has(mtags.msgid)) { + console.log(`Skipping duplicate CHANNNOTICE with msgid: ${mtags.msgid}`); + return; + } + } + + // Check if sender is ignored + const globalSettings = useStore.getState().globalSettings; + if ( + isUserIgnored( + response.sender, + undefined, + undefined, + globalSettings.ignoreList, + ) + ) { + // User is ignored, skip processing this notice + return; + } + + // Find the server + const server = useStore + .getState() + .servers.find((s) => s.id === response.serverId); + + if (!server) return; + + // Check if this is a JSON log notice + const isJsonLog = mtags?.["unrealircd.org/json-log"]; + let jsonLogData = null; + if (isJsonLog) { + try { + const jsonString = mtags["unrealircd.org/json-log"]; + // Log the raw JSON string for debugging (first 200 chars) + console.log( + "Raw JSON log data:", + jsonString.substring(0, 200) + (jsonString.length > 200 ? "..." : ""), + ); + jsonLogData = JSON.parse(jsonString); + } catch (error) { + console.error("Failed to parse JSON log:", error); + console.error("Raw JSON string was:", mtags["unrealircd.org/json-log"]); + // Try to clean up common issues + try { + const cleanedJson = mtags["unrealircd.org/json-log"] + // Replace all \s with spaces (UnrealIRCd uses \s as non-standard space escape) + .replace(/\\s/g, " ") + // Handle other potential escape issues + .replace(/\\'/g, "'") + .replace(/\\&/g, "&"); + + jsonLogData = JSON.parse(cleanedJson); + console.log("Successfully parsed after cleanup"); + } catch (cleanupError) { + console.error("Failed to parse even after cleanup:", cleanupError); + // Try a more aggressive cleanup + try { + const aggressiveClean = mtags["unrealircd.org/json-log"] + .replace(/\\s/g, " ") // Replace all \s with spaces + .replace(/\\'/g, "'") // Replace \' with ' + .replace(/\\&/g, "&"); // Replace \& with & + + jsonLogData = JSON.parse(aggressiveClean); + console.log("Successfully parsed with aggressive cleanup"); + } catch (aggressiveError) { + console.error("Failed aggressive cleanup:", aggressiveError); + // As a last resort, try to extract what we can + try { + // Look for JSON-like structure and extract key parts + const jsonStr = mtags["unrealircd.org/json-log"]; + const extracted: Record = {}; + // Try to extract common fields manually + const timeMatch = jsonStr.match(/"timestamp":"([^"]+)"/); + if (timeMatch) extracted.timestamp = timeMatch[1]; + const levelMatch = jsonStr.match(/"level":"([^"]+)"/); + if (levelMatch) extracted.level = levelMatch[1]; + const msgMatch = jsonStr.match(/"msg":"([^"]+)"/); + if (msgMatch) { + // Clean the message + extracted.msg = msgMatch[1].replace(/\\s/g, " "); + } + if (Object.keys(extracted).length > 0) { + jsonLogData = extracted; + console.log("Extracted partial data:", extracted); + } + } catch (extractError) { + console.error("Failed to extract partial data:", extractError); + } + } + } + } + } + + // Route all server notices to the server notices channel + const targetChannelId = "server-notices"; + + const newMessage: Message = { + id: uuidv4(), + type: isJsonLog ? "notice" : "notice", // Keep as notice type + content: message, + timestamp: timestamp, + userId: response.sender, + channelId: targetChannelId, + serverId: server.id, + reactions: [], + replyMessage: null, + mentioned: [], + tags: mtags, + jsonLogData, // Add parsed JSON log data + }; + + // Mark this message ID as processed to prevent duplicates + if (mtags?.msgid) { + useStore.setState((state) => ({ + processedMessageIds: new Set([...state.processedMessageIds, mtags.msgid]), + })); + } + + useStore.getState().addMessage(newMessage); + + // Play notification sound if appropriate (but not for historical messages) + // Don't count unread/mentions for historical messages (batch tag indicates chathistory playback) + const isHistoricalMessage = mtags?.batch !== undefined; + + if (!isHistoricalMessage) { + const state = useStore.getState(); + const serverCurrentUser = ircClient.getCurrentUser(response.serverId); + if ( + shouldPlayNotificationSound( + newMessage, + serverCurrentUser, + state.globalSettings, + ) + ) { + playNotificationSound(state.globalSettings); + } + } +}); + +ircClient.on("USERNOTICE", (response) => { + const { mtags, message, timestamp } = response; + + // Check for duplicate messages based on msgid + if (mtags?.msgid) { + const currentState = useStore.getState(); + if (currentState.processedMessageIds.has(mtags.msgid)) { + console.log(`Skipping duplicate USERNOTICE with msgid: ${mtags.msgid}`); + return; + } + } + + // Check if sender is ignored + const globalSettings = useStore.getState().globalSettings; + if ( + isUserIgnored( + response.sender, + undefined, + undefined, + globalSettings.ignoreList, + ) + ) { + // User is ignored, skip processing this notice + return; + } + + // Find the server + const server = useStore + .getState() + .servers.find((s) => s.id === response.serverId); + + if (!server) return; + + // Check if this NOTICE is from the server itself (sender contains a ".") + // Server notices should go to Server Notices, user notices should create PM tabs + if (response.sender.includes(".")) { + console.log( + "[USERNOTICE] Server notice detected, routing to Server Notices:", + response.sender, + ); + + // Check if this is a JSON log notice + const isJsonLog = mtags?.["unrealircd.org/json-log"]; + let jsonLogData = null; + if (isJsonLog) { + try { + const jsonString = mtags["unrealircd.org/json-log"]; + // Log the raw JSON string for debugging (first 200 chars) + console.log( + "Raw JSON log data:", + jsonString.substring(0, 200) + (jsonString.length > 200 ? "..." : ""), + ); + jsonLogData = JSON.parse(jsonString); + } catch (error) { + console.error("Failed to parse JSON log:", error); + console.error("Raw JSON string was:", mtags["unrealircd.org/json-log"]); + // Try to clean up common issues + try { + const cleanedJson = mtags["unrealircd.org/json-log"] + // Replace all \s with spaces (UnrealIRCd uses \s as non-standard space escape) + .replace(/\\s/g, " ") + // Handle other potential escape issues + .replace(/\\'/g, "'") + .replace(/\\&/g, "&"); + + jsonLogData = JSON.parse(cleanedJson); + console.log("Successfully parsed after cleanup"); + } catch (cleanupError) { + console.error("Failed to parse even after cleanup:", cleanupError); + // Try a more aggressive cleanup + try { + const aggressiveClean = mtags["unrealircd.org/json-log"] + .replace(/\\s/g, " ") // Replace all \s with spaces + .replace(/\\'/g, "'") // Replace \' with ' + .replace(/\\&/g, "&"); // Replace \& with & + + jsonLogData = JSON.parse(aggressiveClean); + console.log("Successfully parsed with aggressive cleanup"); + } catch (aggressiveError) { + console.error("Failed aggressive cleanup:", aggressiveError); + // As a last resort, try to extract what we can + try { + // Look for JSON-like structure and extract key parts + const jsonStr = mtags["unrealircd.org/json-log"]; + const extracted: Record = {}; + // Try to extract common fields manually + const timeMatch = jsonStr.match(/"timestamp":"([^"]+)"/); + if (timeMatch) extracted.timestamp = timeMatch[1]; + const levelMatch = jsonStr.match(/"level":"([^"]+)"/); + if (levelMatch) extracted.level = levelMatch[1]; + const msgMatch = jsonStr.match(/"msg":"([^"]+)"/); + if (msgMatch) { + // Clean the message + extracted.msg = msgMatch[1].replace(/\\s/g, " "); + } + if (Object.keys(extracted).length > 0) { + jsonLogData = extracted; + console.log("Extracted partial data:", extracted); + } + } catch (extractError) { + console.error("Failed to extract partial data:", extractError); + } + } + } + } + } + + // Route server notices to the server notices channel + const targetChannelId = "server-notices"; + + const newMessage: Message = { + id: uuidv4(), + type: isJsonLog ? "notice" : "notice", // Keep as notice type + content: message, + timestamp: timestamp, + userId: response.sender, + channelId: targetChannelId, + serverId: server.id, + reactions: [], + replyMessage: null, + mentioned: [], + tags: mtags, + jsonLogData, // Add parsed JSON log data + }; + + // Mark this message ID as processed to prevent duplicates + if (mtags?.msgid) { + useStore.setState((state) => ({ + processedMessageIds: new Set([ + ...state.processedMessageIds, + mtags.msgid, + ]), + })); + } + + useStore.getState().addMessage(newMessage); + + // Play notification sound if appropriate (but not for historical messages) + // Don't count unread/mentions for historical messages (batch tag indicates chathistory playback) + const isHistoricalMessage = mtags?.batch !== undefined; + + if (!isHistoricalMessage) { + const state = useStore.getState(); + const serverCurrentUser = ircClient.getCurrentUser(response.serverId); + if ( + shouldPlayNotificationSound( + newMessage, + serverCurrentUser, + state.globalSettings, + ) + ) { + playNotificationSound(state.globalSettings); + } + } + + return; // Don't process as a user notice + } + + // This is a user notice - treat it like a PM + console.log( + "[USERNOTICE] User notice detected, creating PM tab:", + response.sender, + ); + + // Don't create private chats with ourselves + const currentUser = ircClient.getCurrentUser(response.serverId); + if (currentUser?.username === response.sender) { + return; + } + + // Find or create private chat + let privateChat = server.privateChats?.find( + (pc) => pc.username === response.sender, + ); + + if (!privateChat) { + // Auto-create private chat when receiving a notice + useStore.getState().openPrivateChat(server.id, response.sender); + // Get the newly created private chat + privateChat = useStore + .getState() + .servers.find((s) => s.id === server.id) + ?.privateChats?.find((pc) => pc.username === response.sender); + } + + if (privateChat) { + const newMessage: Message = { + id: uuidv4(), + msgid: mtags?.msgid, + content: message, + timestamp, + userId: response.sender, + channelId: privateChat.id, // Use private chat ID as channel ID + serverId: server.id, + type: "notice" as const, // Mark as notice type + reactions: [], + replyMessage: null, + mentioned: [], // PMs don't have mentions in the traditional sense + tags: mtags, + }; + + useStore.getState().addMessage(newMessage); + + // Update private chat's last activity and unread count + const isHistoricalMessage = mtags?.batch !== undefined; + + // Play notification sound if appropriate (but not for historical messages) + if (!isHistoricalMessage) { + const state = useStore.getState(); + const serverCurrentUser = ircClient.getCurrentUser(response.serverId); + if ( + shouldPlayNotificationSound( + newMessage, + serverCurrentUser, + state.globalSettings, + ) + ) { + playNotificationSound(state.globalSettings); + } + } + + useStore.setState((state) => { + const updatedServers = state.servers.map((s) => { + if (s.id === response.serverId) { + const updatedPrivateChats = + s.privateChats?.map((pc) => { + if (pc.id === privateChat.id) { + const isActive = + getCurrentSelection(state).selectedPrivateChatId === pc.id; + return { + ...pc, + lastActivity: new Date(), + unreadCount: + isActive || isHistoricalMessage ? 0 : pc.unreadCount + 1, + isMentioned: !isHistoricalMessage && true, // All PMs are considered mentions (except historical) + }; + } + return pc; + }) || []; + return { ...s, privateChats: updatedPrivateChats }; + } + return s; + }); + return { servers: updatedServers }; + }); + } +}); + +ircClient.on( + "JOIN", + ({ serverId, username, channelName, batchTag, account, realname }) => { + // If this event is part of a batch, store it for later processing + if (batchTag) { + const state = useStore.getState(); + const batch = state.activeBatches[serverId]?.[batchTag]; + if (batch) { + batch.events.push({ + type: "JOIN", + data: { serverId, username, channelName, account, realname }, + }); + return; + } + } + + useStore.setState((state) => { + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + const existingChannel = server.channels.find( + (channel) => + channel.name.toLowerCase() === channelName.toLowerCase(), + ); + + if (!existingChannel) { + const newChannel: Channel = { + id: uuidv4(), + name: channelName, + topic: "", + isPrivate: false, + serverId, + unreadCount: 0, + isMentioned: false, + messages: [], + users: [], + }; + + return { + ...server, + channels: [...server.channels, newChannel], + }; + } + + // If channel exists but with different case, update the name to match server's canonical case + if (existingChannel.name !== channelName) { + const updatedChannels = server.channels.map((channel) => + channel.name.toLowerCase() === channelName.toLowerCase() + ? { ...channel, name: channelName } + : channel, + ); + return { + ...server, + channels: updatedChannels, + }; + } + const updatedChannels = server.channels.map((channel) => { + if (channel.name.toLowerCase() === channelName.toLowerCase()) { + const userAlreadyExists = channel.users.some( + (user) => user.username === username, + ); + if (!userAlreadyExists) { + // Check if this user already exists in other channels and copy their metadata + let userMetadata = {}; + const existingUserInOtherChannels = server.channels + .filter( + (ch) => ch.name.toLowerCase() !== channelName.toLowerCase(), + ) + .flatMap((ch) => ch.users) + .find((user) => user.username === username); + + if (existingUserInOtherChannels) { + userMetadata = { ...existingUserInOtherChannels.metadata }; + } else { + // Check if this is the current user and copy their metadata + const ircCurrentUser = ircClient.getCurrentUser(serverId); + const isCurrentUser = ircCurrentUser?.username === username; + if (isCurrentUser && ircCurrentUser) { + userMetadata = { ...ircCurrentUser.metadata }; + } + } + + return { + ...channel, + users: [ + ...channel.users, + { + id: uuidv4(), // Again, give them a unique ID + username, + isOnline: true, + status: "", + account, // Store account from extended-join if available + realname, // Store realname from extended-join if available + metadata: userMetadata, + }, + ], + }; + } + } + return channel; + }); + + return { ...server, channels: updatedChannels }; + } + + return server; + }); + + // Request metadata for the joining user to get their current metadata + // This is needed for users who join after we're already in the channel + useStore.getState().metadataList(serverId, username); + + return { servers: updatedServers }; + }); + + // If we joined a channel that doesn't exist in the store yet, create it + const ourNick = ircClient.getNick(serverId); + if (username === ourNick) { + const currentState = useStore.getState(); + const serverData = currentState.servers.find((s) => s.id === serverId); + const channelData = serverData?.channels.find( + (c) => c.name === channelName, + ); + + if (!channelData) { + // Channel doesn't exist in store, create it (similar to joinChannel) + const newChannel = { + id: uuidv4(), + name: channelName, + topic: "", + isPrivate: false, + serverId, + unreadCount: 0, + isMentioned: false, + messages: [], + users: [], + isLoadingHistory: true, // Start in loading state + needsWhoRequest: true, // Need to request WHO after CHATHISTORY completes + chathistoryRequested: true, // Mark that we've requested CHATHISTORY + }; + + // Add channel to store + useStore.setState((state) => ({ + servers: state.servers.map((server) => { + if (server.id === serverId) { + return { + ...server, + channels: [...server.channels, newChannel], + }; + } + return server; + }), + })); + + // Request CHATHISTORY for the new channel if server supports it + if (serverData?.capabilities?.includes("draft/chathistory")) { + ircClient.sendRaw(serverId, `CHATHISTORY LATEST ${channelName} * 50`); + + // Trigger event to notify store that history loading started + ircClient.triggerEvent("CHATHISTORY_LOADING", { + serverId, + channelName, + isLoading: true, + }); + } else { + // Server doesn't support CHATHISTORY, mark as not requested and not loading + useStore.setState((state) => ({ + servers: state.servers.map((server) => { + if (server.id === serverId) { + return { + ...server, + channels: server.channels.map((channel) => { + if (channel.name === channelName) { + return { + ...channel, + chathistoryRequested: false, + isLoadingHistory: false, + needsWhoRequest: true, + }; + } + return channel; + }), + }; + } + return server; + }), + })); + } + } else if (!channelData.chathistoryRequested) { + // Channel exists but CHATHISTORY hasn't been requested yet, request it + useStore.setState((state) => ({ + servers: state.servers.map((server) => { + if (server.id === serverId) { + return { + ...server, + channels: server.channels.map((channel) => { + if (channel.name === channelName) { + return { + ...channel, + isLoadingHistory: true, + needsWhoRequest: true, + chathistoryRequested: true, + }; + } + return channel; + }), + }; + } + return server; + }), + })); + + // Request CHATHISTORY for the existing channel if server supports it + if (serverData?.capabilities?.includes("draft/chathistory")) { + ircClient.sendRaw(serverId, `CHATHISTORY LATEST ${channelName} * 50`); + + // Trigger event to notify store that history loading started + ircClient.triggerEvent("CHATHISTORY_LOADING", { + serverId, + channelName, + isLoading: true, + }); + } else { + // Server doesn't support CHATHISTORY, don't set loading state + useStore.setState((state) => ({ + servers: state.servers.map((server) => { + if (server.id === serverId) { + return { + ...server, + channels: server.channels.map((channel) => { + if (channel.name === channelName) { + return { + ...channel, + chathistoryRequested: false, + isLoadingHistory: false, + needsWhoRequest: true, + }; + } + return channel; + }), + }; + } + return server; + }), + })); + } + } + } + + // If we joined a channel, request channel information + if (username === ourNick) { + // Find the channel in the store + const currentState = useStore.getState(); + const serverData = currentState.servers.find((s) => s.id === serverId); + const channelData = serverData?.channels.find( + (c) => c.name === channelName, + ); + + if (channelData?.isLoadingHistory) { + // CHATHISTORY is still loading, defer WHO request until it completes + useStore.setState((state) => ({ + servers: state.servers.map((server) => { + if (server.id === serverId) { + return { + ...server, + channels: server.channels.map((channel) => { + if (channel.name === channelName) { + return { ...channel, needsWhoRequest: true }; + } + return channel; + }), + }; + } + return server; + }), + })); + } else { + // Request topic and user list with WHOX to get account information + ircClient.sendRaw(serverId, `TOPIC ${channelName}`); + // Use WHOX to get user info: c=channel, u=username, h=hostname, n=nickname, f=flags, a=account, r=realname, o=op level + ircClient.sendRaw(serverId, `WHO ${channelName} %cuhnfaro`); + + // Request channel metadata if server supports it + if (serverSupportsMetadata(serverId)) { + setTimeout(() => { + ircClient.metadataGet(serverId, channelName, [ + "avatar", + "display-name", + ]); + }, 100); + } + } + } + + // Add join message if settings allow + const state = useStore.getState(); + if ( + state.globalSettings.showEvents && + state.globalSettings.showJoinsParts + ) { + const server = state.servers.find((s) => s.id === serverId); + if (server) { + const channel = server.channels.find( + (c) => c.name.toLowerCase() === channelName.toLowerCase(), + ); + if (channel) { + const joinMessage: Message = { + id: uuidv4(), + type: "join", + content: `joined ${channelName}`, + timestamp: new Date(), + userId: username, + channelId: channel.id, + serverId: serverId, + reactions: [], + replyMessage: null, + mentioned: [], + }; + + const key = `${serverId}-${channel.id}`; + useStore.setState((state) => ({ + messages: { + ...state.messages, + [key]: [...(state.messages[key] || []), joinMessage], + }, + })); + } + } + } + }, +); + +// Handle user changing their nickname +ircClient.on("NICK", ({ serverId, oldNick, newNick }) => { + useStore.setState((state) => { + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + const updatedChannels = server.channels.map((channel) => { + const updatedUsers = channel.users.map((user) => { + if (user.username === oldNick) { + return { ...user, username: newNick }; // Update the username + } + return user; + }); + return { ...channel, users: updatedUsers }; + }); + return { ...server, channels: updatedChannels }; + } + return server; + }); + + // Update currentUser only if this nick change is for the currently selected server + // and it's our own nick that changed + let updatedCurrentUser = state.currentUser; + const isSelectedServer = state.ui.selectedServerId === serverId; + const serverCurrentUser = ircClient.getCurrentUser(serverId); + const isOurNick = + serverCurrentUser?.username === oldNick || + serverCurrentUser?.username === newNick; + + if ( + isSelectedServer && + isOurNick && + state.currentUser && + state.currentUser.username === oldNick + ) { + updatedCurrentUser = { ...state.currentUser, username: newNick }; + } + + return { + servers: updatedServers, + currentUser: updatedCurrentUser, + }; + }); + + // Add nick change messages to all channels where the user was present + const state = useStore.getState(); + const server = state.servers.find((s) => s.id === serverId); + if ( + server && + state.globalSettings.showEvents && + state.globalSettings.showNickChanges + ) { + // Check if this was our own nick change + const ourNick = ircClient.getNick(serverId); + const isOurNickChange = oldNick === ourNick || newNick === ourNick; + + // Add message to each channel where the user was present + server.channels.forEach((channel) => { + const userWasInChannel = channel.users.some( + (user) => user.username === newNick, + ); + if (userWasInChannel) { + const nickChangeMessage: Message = { + id: uuidv4(), + type: "nick", + content: isOurNickChange + ? `are now known as **${newNick}**` + : `is now known as **${newNick}**`, + timestamp: new Date(), + userId: oldNick, // Use the old nick as the user ID for nick changes + channelId: channel.id, + serverId: serverId, + reactions: [], + replyMessage: null, + mentioned: [], + }; + + const key = `${serverId}-${channel.id}`; + useStore.setState((state) => ({ + messages: { + ...state.messages, + [key]: [...(state.messages[key] || []), nickChangeMessage], + }, + })); + } + }); + + // Also add to private chat if we have one open with this user + const privateChat = server.privateChats?.find( + (pc) => pc.username === oldNick || pc.username === newNick, + ); + if (privateChat) { + // Update the private chat username + useStore.setState((state) => { + const updatedServers = state.servers.map((s) => { + if (s.id === serverId) { + const updatedPrivateChats = s.privateChats?.map((pc) => { + if (pc.username === oldNick) { + return { ...pc, username: newNick }; + } + return pc; + }); + return { ...s, privateChats: updatedPrivateChats }; + } + return s; + }); + return { servers: updatedServers }; + }); + + // Add nick change message to private chat + const nickChangeMessage: Message = { + id: uuidv4(), + type: "nick", + content: isOurNickChange + ? `are now known as **${newNick}**` + : `is now known as **${newNick}**`, + timestamp: new Date(), + userId: oldNick, + channelId: privateChat.id, + serverId: serverId, + reactions: [], + replyMessage: null, + mentioned: [], + }; + + const key = `${serverId}-${privateChat.id}`; + useStore.setState((state) => ({ + messages: { + ...state.messages, + [key]: [...(state.messages[key] || []), nickChangeMessage], + }, + })); + } + + // Note: IRC client already handles updating its internal nick storage + } +}); + +ircClient.on("QUIT", ({ serverId, username, reason, batchTag }) => { + // If this event is part of a batch, store it for later processing + if (batchTag) { + const state = useStore.getState(); + const batch = state.activeBatches[serverId]?.[batchTag]; + if (batch) { + batch.events.push({ + type: "QUIT", + data: { serverId, username, reason }, + }); + return; + } + } + + // Get the current state to check which channels the user was in before removing them + const state = useStore.getState(); + const server = state.servers.find((s) => s.id === serverId); + const channelsUserWasIn: string[] = []; + + if (server) { + server.channels.forEach((channel) => { + const userWasInChannel = channel.users.some( + (user) => user.username === username, + ); + if (userWasInChannel) { + channelsUserWasIn.push(channel.id); + } + }); + } + + useStore.setState((state) => { + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + const updatedChannels = server.channels.map((channel) => { + const updatedUsers = channel.users.filter( + (user) => user.username !== username, + ); + return { ...channel, users: updatedUsers }; + }); + + return { ...server, channels: updatedChannels }; + } + return server; + }); + + return { servers: updatedServers }; + }); + + // Add quit message if settings allow + if (state.globalSettings.showEvents && state.globalSettings.showQuits) { + if (server) { + // Add quit message to all channels where the user was present + server.channels.forEach((channel) => { + if (channelsUserWasIn.includes(channel.id)) { + const quitMessage: Message = { + id: uuidv4(), + type: "quit", + content: reason ? `quit (${reason})` : "quit", + timestamp: new Date(), + userId: username, + channelId: channel.id, + serverId: serverId, + reactions: [], + replyMessage: null, + mentioned: [], + }; + + const key = `${serverId}-${channel.id}`; + useStore.setState((state) => ({ + messages: { + ...state.messages, + [key]: [...(state.messages[key] || []), quitMessage], + }, + })); + } + }); + } + } + + // Remove typing notifications and clear timers for the user who quit from all channels + if (server) { + channelsUserWasIn.forEach((channelId) => { + const key = `${serverId}-${channelId}`; + useStore.setState((state) => { + const currentUsers = state.typingUsers[key] || []; + const currentTimers = state.typingTimers[key] || {}; + + // Clear timer if it exists + if (currentTimers[username]) { + clearTimeout(currentTimers[username]); + } + + const { [username]: removedTimer, ...remainingTimers } = currentTimers; + + return { + typingUsers: { + ...state.typingUsers, + [key]: currentUsers.filter((u) => u.username !== username), + }, + typingTimers: { + ...state.typingTimers, + [key]: remainingTimers, + }, + }; + }); + }); + } +}); + +ircClient.on("ready", async ({ serverId, serverName, nickname }) => { + // Restore metadata for this server + restoreServerMetadata(serverId); + + // Send saved metadata to the server (after 001 ready) + // Only if server supports metadata + if (serverSupportsMetadata(serverId)) { + // First, subscribe to metadata updates + const currentSubs = + useStore.getState().metadataSubscriptions[serverId] || []; + if (currentSubs.length === 0) { + const defaultKeys = [ + "url", + "website", + "status", + "location", + "avatar", + "color", + "display-name", + "bot", + ]; + useStore.getState().metadataSub(serverId, defaultKeys); + } else { + } + + // Fetch our own metadata from the server first + // This will update saved values with what the server has + await fetchAndMergeOwnMetadata(serverId); + + // Now send any metadata we have saved (updated values after merge) + const savedMetadata = loadSavedMetadata(); + const serverMetadata = savedMetadata[serverId]; + const ourNick = ircClient.getNick(serverId); + + if (serverMetadata && ourNick) { + const ourMetadata = serverMetadata[ourNick]; + if (ourMetadata) { + // Send our own metadata to the server + Object.entries(ourMetadata).forEach(([key, { value, visibility }]) => { + if (value !== undefined) { + useStore + .getState() + .metadataSet(serverId, "*", key, value, visibility); + } + }); + } + } + } else { + } + + useStore.setState((state) => { + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + return { ...server, name: serverName }; // Update the server name for display purposes + } + return server; + }); + + const ircCurrentUser = ircClient.getCurrentUser(serverId); + let updatedCurrentUser = state.currentUser; + + if (ircCurrentUser) { + // Get saved metadata for this user on this server + const savedMetadata = loadSavedMetadata(); + const serverMetadata = savedMetadata[serverId]; + const userMetadata = serverMetadata?.[ircCurrentUser.username] || {}; + + // Create current user with IRC data and any saved metadata + updatedCurrentUser = { + ...ircCurrentUser, + metadata: { + ...(state.currentUser?.metadata || {}), + ...userMetadata, + }, + }; + } + + return { + servers: updatedServers, + currentUser: updatedCurrentUser, + }; + }); + + const savedServers = loadSavedServers(); + const savedServer = savedServers.find((s) => s.id === serverId); + + if (savedServer) { + // Send OPER command if oper on connect is enabled + if ( + savedServer.operOnConnect && + savedServer.operUsername && + savedServer.operPassword + ) { + try { + const decodedPassword = atob(savedServer.operPassword); + useStore + .getState() + .sendRaw( + serverId, + `OPER ${savedServer.operUsername} ${decodedPassword}`, + ); + } catch (error) { + console.error("Failed to decode operator password:", error); + // Fall back to using the password as-is if decoding fails + useStore + .getState() + .sendRaw( + serverId, + `OPER ${savedServer.operUsername} ${savedServer.operPassword}`, + ); + } + } + + // Get the saved channel order for this server + const savedChannelOrder = useStore.getState().channelOrder[serverId]; + + // If we have a saved order, use it to determine join sequence + let channelsToJoin: string[] = savedServer.channels; + + if (savedChannelOrder && savedChannelOrder.length > 0) { + // Map channel IDs to channel names using the saved order + // Note: savedChannelOrder has IDs, but we need names for joining + // We'll join in the order from savedServer.channels which should already be ordered + channelsToJoin = savedServer.channels; + } + + for (const channelName of channelsToJoin) { + if (channelName) { + useStore.getState().joinChannel(serverId, channelName); + } + } + + // Update the UI state to reflect the first joined channel + useStore.setState((state) => ({ + ui: { + ...state.ui, + selectedServerId: serverId, + selectedChannelId: savedServer.channels[0] || null, + }, + })); + } else { + } + + // Restore pinned private chats for this server + const pinnedChats = loadPinnedPrivateChats(); + const serverPinnedChats = pinnedChats[serverId] || []; + + if (serverPinnedChats.length > 0) { + // Sort by order + const sortedPinnedChats = [...serverPinnedChats].sort( + (a, b) => a.order - b.order, + ); + + useStore.setState((state) => { + const server = state.servers.find((s) => s.id === serverId); + if (!server) return {}; + + // Create private chat objects for pinned users + const restoredPrivateChats: PrivateChat[] = sortedPinnedChats.map( + ({ username, order }) => ({ + id: uuidv4(), + username, + serverId, + unreadCount: 0, + isMentioned: false, + lastActivity: new Date(), + isPinned: true, + order, + isOnline: false, // Will be updated by MONITOR + isAway: false, + }), + ); + + const updatedServers = state.servers.map((s) => { + if (s.id === serverId) { + // Merge existing private chats with restored pinned chats, deduplicating by username + const existingChats = s.privateChats || []; + const mergedPrivateChats = [...existingChats]; + + for (const restoredChat of restoredPrivateChats) { + const existingIndex = mergedPrivateChats.findIndex( + (pc) => pc.username === restoredChat.username, + ); + if (existingIndex === -1) { + // Chat doesn't exist, add it + mergedPrivateChats.push(restoredChat); + } else { + // Chat exists, ensure it's marked as pinned with correct order + mergedPrivateChats[existingIndex] = { + ...mergedPrivateChats[existingIndex], + isPinned: true, + order: restoredChat.order, + }; + } + } + + return { + ...s, + privateChats: mergedPrivateChats, + }; + } + return s; + }); + + return { servers: updatedServers }; + }); + + // MONITOR all pinned users + const usernames = sortedPinnedChats.map((pc) => pc.username); + ircClient.monitorAdd(serverId, usernames); + + // Request chathistory for each pinned PM + setTimeout(() => { + for (const { username } of sortedPinnedChats) { + ircClient.sendRaw(serverId, `CHATHISTORY LATEST ${username} * 50`); + } + }, 50); + + // For each pinned user, check if we have their info from channels first + setTimeout(() => { + const state = useStore.getState(); + const server = state.servers.find((s) => s.id === serverId); + if (!server) return; + + for (const { username } of sortedPinnedChats) { + // Check if we already have user info from channels + let hasUserInfo = false; + for (const channel of server.channels) { + const user = channel.users.find( + (u) => u.username.toLowerCase() === username.toLowerCase(), + ); + if (user?.realname && user.account !== undefined) { + // We have complete user info, copy it to the PM + hasUserInfo = true; + useStore.setState((state) => ({ + servers: state.servers.map((s) => { + if (s.id === serverId) { + return { + ...s, + privateChats: s.privateChats?.map((pm) => { + if (pm.username === username) { + return { + ...pm, + realname: user.realname, + account: user.account, + isBot: user.isBot, + }; + } + return pm; + }), + }; + } + return s; + }), + })); + break; + } + } + + // Only request WHO if we don't have complete user info + if (!hasUserInfo) { + // Request WHO to get current status using WHOX to also get account + // Fields: u=username, h=hostname, n=nickname, f=flags, a=account, r=realname + ircClient.sendRaw(serverId, `WHO ${username} %cuhnfrao`); + } + } + }, 100); + + // Note: We don't request METADATA GET for individual users as some servers reject this. + // Instead, we rely on metadata from shared channels (if user is in a channel with us) + // or from localStorage if we previously got their metadata. + } +}); + +ircClient.on( + "EXTJWT", + ({ serverId, requestedTarget, serviceName, jwtToken }) => { + console.log("🔑 EXTJWT received:", { + serverId, + requestedTarget, + serviceName, + jwtToken: jwtToken ? "present" : "missing", + }); + useStore.setState((state) => { + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + return { ...server, jwtToken }; + } + return server; + }); + return { servers: updatedServers }; + }); + }, +); + +ircClient.on("PART", ({ serverId, username, channelName, reason }) => { + useStore.setState((state) => { + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + const updatedChannels = server.channels.map((channel) => { + if (channel.name.toLowerCase() === channelName.toLowerCase()) { + return { + ...channel, + users: channel.users.filter((user) => user.username !== username), // Remove the user + }; + } + return channel; + }); + return { ...server, channels: updatedChannels }; + } + return server; + }); + + return { servers: updatedServers }; + }); + + // Add part message if settings allow + const state = useStore.getState(); + if (state.globalSettings.showEvents && state.globalSettings.showJoinsParts) { + const server = state.servers.find((s) => s.id === serverId); + if (server) { + const channel = server.channels.find((c) => c.name === channelName); + if (channel) { + const partMessage: Message = { + id: uuidv4(), + type: "part", + content: reason + ? `left ${channelName} (${reason})` + : `left ${channelName}`, + timestamp: new Date(), + userId: username, + channelId: channel.id, + serverId: serverId, + reactions: [], + replyMessage: null, + mentioned: [], + }; + + const key = `${serverId}-${channel.id}`; + useStore.setState((state) => ({ + messages: { + ...state.messages, + [key]: [...(state.messages[key] || []), partMessage], + }, + })); + } + } + } + + // Remove typing notification and clear timer for the user who parted + const server = state.servers.find((s) => s.id === serverId); + if (server) { + const channel = server.channels.find((c) => c.name === channelName); + if (channel) { + const key = `${serverId}-${channel.id}`; + useStore.setState((state) => { + const currentUsers = state.typingUsers[key] || []; + const currentTimers = state.typingTimers[key] || {}; + + // Clear timer if it exists + if (currentTimers[username]) { + clearTimeout(currentTimers[username]); + } + + const { [username]: removedTimer, ...remainingTimers } = currentTimers; + + return { + typingUsers: { + ...state.typingUsers, + [key]: currentUsers.filter((u) => u.username !== username), + }, + typingTimers: { + ...state.typingTimers, + [key]: remainingTimers, + }, + }; + }); + } + } +}); + +ircClient.on("MODE", ({ serverId, sender, target, modestring, modeargs }) => { + // Handle user mode responses (channel modes are handled by the protocol handler) + if (!target.startsWith("#")) { + // This is a user mode change + useStore.setState((state) => { + // Check if this is the current user + const currentUser = state.currentUser; + if ( + currentUser && + currentUser.username.toLowerCase() === target.toLowerCase() + ) { + // Check if this is an IRC operator mode change + const isIrcOp = modestring.includes("o"); + // Update current user's modes and IRC operator status + return { + currentUser: { + ...currentUser, + modes: modestring, + isIrcOp: isIrcOp, + }, + }; + } + + // If no currentUser in store, check if this MODE is for the IRC current user + const ircCurrentUser = ircClient.getCurrentUser(serverId); + if ( + !currentUser && + ircCurrentUser && + ircCurrentUser.username.toLowerCase() === target.toLowerCase() + ) { + // Check if this is an IRC operator mode change + const isIrcOp = modestring.includes("o"); + // Set the current user with modes and IRC operator status + return { + currentUser: { + ...ircCurrentUser, + modes: modestring, + isIrcOp: isIrcOp, + }, + }; + } + + // Update user in server users list + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + const updatedUsers = server.users.map((user) => { + if (user.username.toLowerCase() === target.toLowerCase()) { + console.log( + "Updated user", + user.username, + "modes to", + modestring, + ); + return { + ...user, + modes: modestring, + }; + } + return user; + }); + return { ...server, users: updatedUsers }; + } + return server; + }); + + return { servers: updatedServers }; + }); + } +}); + +ircClient.on( + "RPL_CHANNELMODEIS", + ({ serverId, channelName, modestring, modeargs }) => { + useStore.setState((state) => { + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + const updatedChannels = server.channels.map((channel) => { + if (channel.name.toLowerCase() === channelName.toLowerCase()) { + return { + ...channel, + modes: modestring, + modeArgs: modeargs, + }; + } + return channel; + }); + return { ...server, channels: updatedChannels }; + } + return server; + }); + return { servers: updatedServers }; + }); + }, +); + +ircClient.on( + "RPL_BANLIST", + ({ serverId, channel, mask, setter, timestamp }) => { + console.log( + `RPL_BANLIST received: serverId=${serverId}, channel=${channel}, mask=${mask}, setter=${setter}, timestamp=${timestamp}`, + ); + useStore.setState((state) => { + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + const updatedChannels = server.channels.map((ch) => { + if (ch.name === channel) { + const bans = ch.bans || []; + // Add the ban if it doesn't already exist + if (!bans.some((ban) => ban.mask === mask)) { + bans.push({ mask, setter, timestamp }); + console.log(`Added ban to channel ${channel}:`, { + mask, + setter, + timestamp, + }); + } else { + console.log(`Ban already exists for channel ${channel}:`, mask); + } + return { ...ch, bans }; + } + return ch; + }); + return { ...server, channels: updatedChannels }; + } + return server; + }); + return { servers: updatedServers }; + }); + }, +); + +ircClient.on( + "RPL_INVITELIST", + ({ serverId, channel, mask, setter, timestamp }) => { + console.log( + `RPL_INVITELIST received: serverId=${serverId}, channel=${channel}, mask=${mask}, setter=${setter}, timestamp=${timestamp}`, + ); + useStore.setState((state) => { + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + const updatedChannels = server.channels.map((ch) => { + if (ch.name === channel) { + const invites = ch.invites || []; + // Add the invite if it doesn't already exist + if (!invites.some((invite) => invite.mask === mask)) { + invites.push({ mask, setter, timestamp }); + console.log(`Added invite to channel ${channel}:`, { + mask, + setter, + timestamp, + }); + } else { + console.log( + `Invite already exists for channel ${channel}:`, + mask, + ); + } + return { ...ch, invites }; + } + return ch; + }); + return { ...server, channels: updatedChannels }; + } + return server; + }); + return { servers: updatedServers }; + }); + }, +); + +ircClient.on( + "RPL_EXCEPTLIST", + ({ serverId, channel, mask, setter, timestamp }) => { + console.log( + `RPL_EXCEPTLIST received: serverId=${serverId}, channel=${channel}, mask=${mask}, setter=${setter}, timestamp=${timestamp}`, + ); + useStore.setState((state) => { + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + const updatedChannels = server.channels.map((ch) => { + if (ch.name === channel) { + const exceptions = ch.exceptions || []; + // Add the exception if it doesn't already exist + if (!exceptions.some((exception) => exception.mask === mask)) { + exceptions.push({ mask, setter, timestamp }); + console.log(`Added exception to channel ${channel}:`, { + mask, + setter, + timestamp, + }); + } else { + console.log( + `Exception already exists for channel ${channel}:`, + mask, + ); + } + return { ...ch, exceptions }; + } + return ch; + }); + return { ...server, channels: updatedChannels }; + } + return server; + }); + return { servers: updatedServers }; + }); + }, +); + +ircClient.on("RPL_ENDOFBANLIST", ({ serverId, channel }) => { + // Ban list loading is complete - could trigger UI updates if needed + console.log(`Ban list loaded for ${channel} on server ${serverId}`); +}); + +ircClient.on("RPL_ENDOFINVITELIST", ({ serverId, channel }) => { + // Invite list loading is complete - could trigger UI updates if needed + console.log(`Invite list loaded for ${channel} on server ${serverId}`); +}); + +ircClient.on("RPL_YOUREOPER", ({ serverId, message }) => { + // Show notification that user is now an IRC operator + useStore.getState().addGlobalNotification({ + type: "note", + command: "Oper", + code: "OPER", + message: "You are an IRC Operator", + serverId, + }); +}); + +ircClient.on("RPL_YOURHOST", ({ serverId, serverName, version }) => { + // Check if the server is running UnrealIRCd + const isUnrealIRCd = version.includes("UnrealIRCd"); + + // Update the server with the UnrealIRCd information + useStore.setState((state) => ({ + servers: state.servers.map((server) => + server.id === serverId ? { ...server, isUnrealIRCd } : server, + ), + })); +}); + +// Topic handlers +ircClient.on("TOPIC", ({ serverId, channelName, topic, sender }) => { + useStore.setState((state) => { + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + const updatedChannels = server.channels.map((channel) => { + if (channel.name.toLowerCase() === channelName.toLowerCase()) { + return { ...channel, topic }; + } + return channel; + }); + return { ...server, channels: updatedChannels }; + } + return server; + }); + return { servers: updatedServers }; + }); + + // Optionally add a system message showing the topic change + const server = useStore.getState().servers.find((s) => s.id === serverId); + const channel = server?.channels.find((c) => c.name === channelName); + if (channel) { + const topicMessage: Message = { + id: `topic-${Date.now()}`, + channelId: channel.id, + userId: sender, + content: `changed the topic to: ${topic}`, + timestamp: new Date(), + serverId: serverId, + reactions: [], + type: "system", + replyMessage: null, + mentioned: [], + }; + + const key = `${serverId}-${channel.id}`; + useStore.setState((state) => ({ + messages: { + ...state.messages, + [key]: [...(state.messages[key] || []), topicMessage], + }, + })); + } +}); + +ircClient.on("RPL_TOPIC", ({ serverId, channelName, topic }) => { + useStore.setState((state) => { + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + const updatedChannels = server.channels.map((channel) => { + if (channel.name.toLowerCase() === channelName.toLowerCase()) { + return { ...channel, topic }; + } + return channel; + }); + return { ...server, channels: updatedChannels }; + } + return server; + }); + return { servers: updatedServers }; + }); +}); + +ircClient.on( + "RPL_TOPICWHOTIME", + ({ serverId, channelName, setter, timestamp }) => { + // This provides metadata about who set the topic and when + // We could store this if we extend the Channel interface + console.log( + `Topic for ${channelName} was set by ${setter} at ${new Date( + timestamp * 1000, + ).toISOString()}`, + ); + }, +); + +ircClient.on("RPL_NOTOPIC", ({ serverId, channelName }) => { + useStore.setState((state) => { + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + const updatedChannels = server.channels.map((channel) => { + if (channel.name.toLowerCase() === channelName.toLowerCase()) { + return { ...channel, topic: undefined }; + } + return channel; + }); + return { ...server, channels: updatedChannels }; + } + return server; + }); + return { servers: updatedServers }; + }); +}); + +// WHOIS event handlers +ircClient.on("WHOIS_USER", ({ serverId, nick, username, host, realname }) => { + useStore.setState((state) => { + const serverWhois = state.whoisData[serverId] || {}; + const existingData = serverWhois[nick] || { + nick, + specialMessages: [], + timestamp: Date.now(), + }; + + return { + whoisData: { + ...state.whoisData, + [serverId]: { + ...serverWhois, + [nick]: { + ...existingData, + username, + host, + realname, + timestamp: Date.now(), + }, + }, + }, + }; + }); +}); + +ircClient.on("WHOIS_SERVER", ({ serverId, nick, server, serverInfo }) => { + useStore.setState((state) => { + const serverWhois = state.whoisData[serverId] || {}; + const existingData = serverWhois[nick] || { + nick, + specialMessages: [], + timestamp: Date.now(), + }; + + return { + whoisData: { + ...state.whoisData, + [serverId]: { + ...serverWhois, + [nick]: { + ...existingData, + server, + serverInfo, + }, + }, + }, + }; + }); +}); + +ircClient.on("WHOIS_IDLE", ({ serverId, nick, idle, signon }) => { + useStore.setState((state) => { + const serverWhois = state.whoisData[serverId] || {}; + const existingData = serverWhois[nick] || { + nick, + specialMessages: [], + timestamp: Date.now(), + }; + + return { + whoisData: { + ...state.whoisData, + [serverId]: { + ...serverWhois, + [nick]: { + ...existingData, + idle, + signon, + }, + }, + }, + }; + }); +}); + +ircClient.on("WHOIS_CHANNELS", ({ serverId, nick, channels }) => { + useStore.setState((state) => { + const serverWhois = state.whoisData[serverId] || {}; + const existingData = serverWhois[nick] || { + nick, + specialMessages: [], + timestamp: Date.now(), + }; + + return { + whoisData: { + ...state.whoisData, + [serverId]: { + ...serverWhois, + [nick]: { + ...existingData, + channels, + }, + }, + }, + }; + }); +}); + +ircClient.on("WHOIS_ACCOUNT", ({ serverId, nick, account }) => { + useStore.setState((state) => { + const serverWhois = state.whoisData[serverId] || {}; + const existingData = serverWhois[nick] || { + nick, + specialMessages: [], + timestamp: Date.now(), + }; + + return { + whoisData: { + ...state.whoisData, + [serverId]: { + ...serverWhois, + [nick]: { + ...existingData, + account, + }, + }, + }, + }; + }); +}); + +ircClient.on("WHOIS_SECURE", ({ serverId, nick, message }) => { + useStore.setState((state) => { + const serverWhois = state.whoisData[serverId] || {}; + const existingData = serverWhois[nick] || { + nick, + specialMessages: [], + timestamp: Date.now(), + }; + + return { + whoisData: { + ...state.whoisData, + [serverId]: { + ...serverWhois, + [nick]: { + ...existingData, + secureConnection: message, + }, + }, + }, + }; + }); +}); + +ircClient.on("WHOIS_SPECIAL", ({ serverId, nick, message }) => { + useStore.setState((state) => { + const serverWhois = state.whoisData[serverId] || {}; + const existingData = serverWhois[nick] || { + nick, + specialMessages: [], + timestamp: Date.now(), + }; + + // Deduplicate special messages + const updatedMessages = existingData.specialMessages.includes(message) + ? existingData.specialMessages + : [...existingData.specialMessages, message]; + + return { + whoisData: { + ...state.whoisData, + [serverId]: { + ...serverWhois, + [nick]: { + ...existingData, + specialMessages: updatedMessages, + }, + }, + }, + }; + }); +}); + +ircClient.on("WHOIS_END", ({ serverId, nick }) => { + // Mark the whois data as complete + console.log(`WHOIS completed for ${nick} on server ${serverId}`); + + useStore.setState((state) => { + const serverWhois = state.whoisData[serverId] || {}; + const existingData = serverWhois[nick]; + + if (existingData) { + return { + whoisData: { + ...state.whoisData, + [serverId]: { + ...serverWhois, + [nick]: { + ...existingData, + isComplete: true, + }, + }, + }, + }; + } + + return state; + }); +}); + +ircClient.on("KICK", ({ serverId, username, target, channelName, reason }) => { + useStore.setState((state) => { + const updatedServers = state.servers.map((server) => { + const updatedChannels = server.channels.map((channel) => { + if (channel.name.toLowerCase() === channelName.toLowerCase()) { + return { + ...channel, + users: channel.users.filter((user) => user.username !== target), // Remove the user + }; + } + return channel; + }); + return { ...server, channels: updatedChannels }; + }); + + return { servers: updatedServers }; + }); + + // Add kick message if settings allow + const state = useStore.getState(); + if (state.globalSettings.showEvents && state.globalSettings.showKicks) { + const server = state.servers.find((s) => s.id === serverId); + if (server) { + const channel = server.channels.find((c) => c.name === channelName); + if (channel) { + const kickMessage: Message = { + id: uuidv4(), + type: "kick", + content: reason + ? `was kicked from ${channelName} by ${username} (${reason})` + : `was kicked from ${channelName} by ${username}`, + timestamp: new Date(), + userId: target, + channelId: channel.id, + serverId: serverId, + reactions: [], + replyMessage: null, + mentioned: [], + }; + + const key = `${serverId}-${channel.id}`; + useStore.setState((state) => ({ + messages: { + ...state.messages, + [key]: [...(state.messages[key] || []), kickMessage], + }, + })); + } + } + } + + // Remove typing notification and clear timer for the kicked user + const server = state.servers.find((s) => s.id === serverId); + if (server) { + const channel = server.channels.find((c) => c.name === channelName); + if (channel) { + const key = `${serverId}-${channel.id}`; + useStore.setState((state) => { + const currentUsers = state.typingUsers[key] || []; + const currentTimers = state.typingTimers[key] || {}; + + // Clear timer if it exists + if (currentTimers[target]) { + clearTimeout(currentTimers[target]); + } + + const { [target]: removedTimer, ...remainingTimers } = currentTimers; + + return { + typingUsers: { + ...state.typingUsers, + [key]: currentUsers.filter((u) => u.username !== target), + }, + typingTimers: { + ...state.typingTimers, + [key]: remainingTimers, + }, + }; + }); + } + } +}); + +ircClient.on("INVITE", ({ serverId, inviter, target, channel }) => { + const state = useStore.getState(); + const server = state.servers.find((s) => s.id === serverId); + if (!server) return; + + // Get current user's nickname to determine the active channel + const currentUser = ircClient.getCurrentUser(serverId); + if (!currentUser) return; + + // Determine where to show the invite message + // Show in the currently selected channel/chat, or fallback to server's first channel + let targetChannelId: string | null = null; + let targetChannelName: string | null = null; + + // If we're on this server and have a selected channel, use that + if (state.ui.selectedServerId === serverId) { + const currentSelection = getCurrentSelection(state); + if (currentSelection.selectedChannelId) { + const selectedChannel = server.channels.find( + (c) => c.id === currentSelection.selectedChannelId, + ); + if (selectedChannel) { + targetChannelId = selectedChannel.id; + targetChannelName = selectedChannel.name; + } + } else if (currentSelection.selectedPrivateChatId) { + // For private chats, we'll show it there + targetChannelId = currentSelection.selectedPrivateChatId; + } + } + + // If no active channel, use the first channel on the server as fallback + if (!targetChannelId && server.channels.length > 0) { + targetChannelId = server.channels[0].id; + targetChannelName = server.channels[0].name; + } + + if (!targetChannelId) return; + + // Create the invite message + const isForCurrentUser = + target.toLowerCase() === currentUser.username.toLowerCase(); + const content = isForCurrentUser + ? `${inviter} has invited you to join ${channel}` + : `${inviter} has invited ${target} to join ${channel}`; + + const inviteMessage: Message = { + id: uuidv4(), + type: "invite", + content, + timestamp: new Date(), + userId: inviter, + channelId: targetChannelId, + serverId: serverId, + reactions: [], + replyMessage: null, + mentioned: [], + inviteChannel: channel, + inviteTarget: target, + }; + + const key = `${serverId}-${targetChannelId}`; + useStore.setState((state) => ({ + messages: { + ...state.messages, + [key]: [...(state.messages[key] || []), inviteMessage], + }, + })); +}); + +ircClient.on("CAP_ACKNOWLEDGED", ({ serverId, key, capabilities }) => { + console.log( + `[CAP_ACKNOWLEDGED] Server ${serverId} acknowledged capability: ${key} (${capabilities})`, + ); + if (capabilities?.startsWith("draft/metadata")) { + // Check if already subscribed to avoid duplicate subscriptions + const currentSubs = + useStore.getState().metadataSubscriptions[serverId] || []; + console.log( + `[CAP_ACKNOWLEDGED] Current metadata subscriptions for server ${serverId}:`, + currentSubs, + ); + if (currentSubs.length === 0) { + // Subscribe to common metadata keys + const defaultKeys = [ + "url", + "website", + "status", + "location", + "avatar", + "color", + "display-name", + "bot", // Subscribe to bot metadata for tooltip information + ]; + console.log( + "[CAP_ACKNOWLEDGED] Attempting to subscribe to default metadata keys:", + defaultKeys, + ); + useStore.getState().metadataSub(serverId, defaultKeys); + } + + // Note: Metadata restoration/sending is now handled in the "ready" event + // to ensure the server is ready to receive METADATA commands + } + if (key === "sasl") { + const servers = loadSavedServers(); + for (const serv of servers) { + if (serv.id !== serverId) continue; + + if (!serv.saslEnabled) return; + } + ircClient.sendRaw(serverId, "AUTHENTICATE PLAIN"); + } +}); + +ircClient.on("AUTHENTICATE", ({ serverId, param }) => { + if (param !== "+") return; + + // Don't respond to AUTHENTICATE if CAP negotiation is already complete + if (ircClient.isCapNegotiationComplete(serverId)) return; + + let user: string | undefined; + let pass: string | undefined; + const servers = loadSavedServers(); + for (const serv of servers) { + if (serv.id !== serverId) continue; + + if (!serv.saslEnabled) return; + + user = serv.saslAccountName?.length ? serv.saslAccountName : serv.nickname; + pass = serv.saslPassword ? atob(serv.saslPassword) : undefined; + } + if (!user || !pass) + // wtf happened lol + return; + + ircClient.sendRaw( + serverId, + `AUTHENTICATE ${btoa(`${user}\x00${user}\x00${pass}`)}`, + ); + // Note: CAP END will be sent by the IRC client when SASL authentication completes (903/904-907 responses) + // ircClient.sendRaw(serverId, "CAP END"); + // ircClient.userOnConnect(serverId); +}); + +// Handle CAP LS to get informational capabilities like unrealircd.org/link-security +ircClient.on("CAP LS", ({ serverId, cliCaps }) => { + // Parse link-security from CAP LS (informational capability) + if (cliCaps.includes("unrealircd.org/link-security=")) { + const match = cliCaps.match(/unrealircd\.org\/link-security=(\d+)/); + if (match) { + const linkSecurityValue = Number.parseInt(match[1], 10) || 0; + + // Update server with link security value + useStore.setState((state) => { + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + return { + ...server, + linkSecurity: linkSecurityValue, + }; + } + return server; + }); + + return { servers: updatedServers }; + }); + + // Check for insecure connection and show warning modal + const currentState = useStore.getState(); + const currentServer = currentState.servers.find((s) => s.id === serverId); + const isLocalhost = + currentServer && + (currentServer.host === "localhost" || + currentServer.host === "127.0.0.1"); + const hasLowLinkSecurity = linkSecurityValue < 2; + + // Check if we should show warning based on individual skip preferences + const savedServers = loadSavedServers(); + const serverConfig = currentServer + ? savedServers.find( + (s) => + s.host === currentServer.host && s.port === currentServer.port, + ) + : undefined; + + const shouldWarnLocalhost = + isLocalhost && !serverConfig?.skipLocalhostWarning; + const shouldWarnLinkSecurity = + hasLowLinkSecurity && !serverConfig?.skipLinkSecurityWarning; + + if (shouldWarnLocalhost || shouldWarnLinkSecurity) { + useStore.setState((state) => { + // Check if warning already exists for this server + const existingWarning = state.ui.linkSecurityWarnings.find( + (w) => w.serverId === serverId, + ); + if (existingWarning) { + return state; // Don't add duplicate warning + } + + return { + ui: { + ...state.ui, + linkSecurityWarnings: [ + ...state.ui.linkSecurityWarnings, + { serverId, timestamp: Date.now() }, + ], + }, + }; + }); + } + } + } +}); + +ircClient.on("CAP ACK", ({ serverId, cliCaps }) => { + const caps = cliCaps.split(" "); + + for (const cap of caps) { + const tok = cap.split("="); + const capName = tok[0]; + const capValue = tok[1]; + + ircClient.capAck(serverId, capName, capValue ?? null); + } + + // Update server capabilities in store + useStore.setState((state) => { + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + return { + ...server, + capabilities: cliCaps.split(" "), + }; + } + return server; + }); + return { servers: updatedServers }; + }); + + // Check if we should prevent CAP END (for SASL, account registration, or link security warning) + const state = useStore.getState(); + const server = state.servers.find((s) => s.id === serverId); + let preventCapEnd = false; + + // Check if SASL was requested and acknowledged, AND we have credentials + if (caps.some((cap) => cap.startsWith("sasl"))) { + // Only prevent CAP END if we actually have SASL credentials + const servers = loadSavedServers(); + const savedServer = servers.find((s) => s.id === serverId); + if ( + savedServer?.saslEnabled && + savedServer?.saslAccountName && + savedServer?.saslPassword + ) { + preventCapEnd = true; + } + } + + // Check if there's pending account registration + const pendingReg = state.pendingRegistration; + if (pendingReg && pendingReg.serverId === serverId) { + preventCapEnd = true; + // Check if server supports account registration + if (server?.capabilities?.includes("draft/account-registration")) { + useStore + .getState() + .registerAccount( + serverId, + pendingReg.account, + pendingReg.email, + pendingReg.password, + ); + // Clear the pending registration + useStore.setState({ pendingRegistration: null }); + } else { + // Clear the pending registration + useStore.setState({ pendingRegistration: null }); + // Send CAP END since registration is not possible + preventCapEnd = false; + } + } + + // Check if link security warning modal is showing - prevent CAP END until user responds + if (state.ui.linkSecurityWarnings.some((w) => w.serverId === serverId)) { + preventCapEnd = true; + } + + if (!preventCapEnd) { + ircClient.sendRaw(serverId, "CAP END"); + ircClient.userOnConnect(serverId); + } else { + } +}); + +ircClient.on("LIST_CHANNEL", ({ serverId, channel, userCount, topic }) => { + useStore.setState((state) => { + if (!state.listingInProgress[serverId]) { + // Not currently listing, ignore + return {}; + } + const currentBuffer = state.channelListBuffer[serverId] || []; + const updatedBuffer = [...currentBuffer, { channel, userCount, topic }]; + return { + channelListBuffer: { + ...state.channelListBuffer, + [serverId]: updatedBuffer, + }, + }; + }); +}); + +ircClient.on("LIST_END", ({ serverId }) => { + // Move buffered channels to the main list and set listing as complete + useStore.setState((state) => ({ + channelList: { + ...state.channelList, + [serverId]: state.channelListBuffer[serverId] || [], + }, + channelListBuffer: { + ...state.channelListBuffer, + [serverId]: [], + }, + listingInProgress: { + ...state.listingInProgress, + [serverId]: false, + }, + })); +}); + +// CTCPs lol +ircClient.on("CHANMSG", (response) => { + const { channelName, message, timestamp } = response; + + // Find the server and channel + const server = useStore + .getState() + .servers.find((s) => s.id === response.serverId); + + if (!server) return; + + const parv = message.split(" "); + if (parv[0] === "\u0001VERSION\u0001") { + ircClient.sendRaw( + server.id, + `NOTICE ${response.sender} :\u0001VERSION ObsidianIRC v${ircClient.version}\u0001`, + ); + } + if (parv[0] === "\u0001PING") { + ircClient.sendRaw( + server.id, + `NOTICE ${response.sender} :\u0001PING ${parv[1]}\u0001`, + ); + } + if (parv[0] === "\u0001TIME\u0001") { + const date = new Date(); + ircClient.sendRaw( + server.id, + `NOTICE ${response.sender} :\u0001TIME ${date.toUTCString()}\u0001`, + ); + } +}); + +// TAGMSG typing +ircClient.on("TAGMSG", (response) => { + const { sender, mtags, channelName } = response; + + // Check if the sender is not the current user for this specific server + // we don't care about showing our own typing status + const currentNick = ircClient.getNick(response.serverId); + if ( + sender.toLowerCase() !== currentNick?.toLowerCase() && + mtags && + mtags["+typing"] + ) { + const isActive = mtags["+typing"] === "active"; + const server = useStore + .getState() + .servers.find((s) => s.id === response.serverId); + + if (!server) return; + + let key: string; + let user: User; + + const isChannel = channelName.startsWith("#"); + if (isChannel) { + const channel = server.channels.find((c) => c.name === channelName); + if (!channel) return; + + const foundUser = channel.users.find( + (u) => u.username === response.sender, + ); + if (!foundUser) return; + user = foundUser; + + key = `${server.id}-${channel.id}`; + } else { + // Private chat + const privateChat = server.privateChats?.find( + (pc) => pc.username === sender, + ); + if (!privateChat) return; + + // For private chats, create a user object + user = { + id: `${server.id}-${sender}`, + username: sender, + isOnline: true, + }; + + key = `${server.id}-${privateChat.id}`; + } + + useStore.setState((state) => { + const currentUsers = state.typingUsers[key] || []; + const currentTimers = state.typingTimers[key] || {}; + + if (isActive) { + // Clear existing timer for this user if it exists + if (currentTimers[user.username]) { + clearTimeout(currentTimers[user.username]); + } + + // Create a new timer to auto-clear typing notification after 6 seconds + const timer = setTimeout(() => { + useStore.setState((state) => { + const currentUsers = state.typingUsers[key] || []; + const currentTimers = state.typingTimers[key] || {}; + + // Remove the timer reference + const { [user.username]: removedTimer, ...remainingTimers } = + currentTimers; + + return { + typingUsers: { + ...state.typingUsers, + [key]: currentUsers.filter((u) => u.username !== user.username), + }, + typingTimers: { + ...state.typingTimers, + [key]: remainingTimers, + }, + }; + }); + }, 6000); + + // Don't add if already in the list + if (currentUsers.some((u) => u.username === user.username)) { + // Update timer even if user is already in list + return { + typingTimers: { + ...state.typingTimers, + [key]: { ...currentTimers, [user.username]: timer }, + }, + }; + } + + return { + typingUsers: { + ...state.typingUsers, + [key]: [...currentUsers, user], + }, + typingTimers: { + ...state.typingTimers, + [key]: { ...currentTimers, [user.username]: timer }, + }, + }; + } + // Remove the user from the list when they send "paused" or "done" + // Clear their timer if it exists + if (currentTimers[user.username]) { + clearTimeout(currentTimers[user.username]); + } + + const { [user.username]: removedTimer, ...remainingTimers } = + currentTimers; + + return { + typingUsers: { + ...state.typingUsers, + [key]: currentUsers.filter((u) => u.username !== user.username), + }, + typingTimers: { + ...state.typingTimers, + [key]: remainingTimers, + }, + }; + }); + } + + // Handle reactions + if (mtags?.["+draft/react"] && mtags["+draft/reply"]) { + const emoji = mtags["+draft/react"]; + const replyMessageId = mtags["+draft/reply"]; + + // Skip processing our own reactions since we handle them optimistically + const currentUser = ircClient.getCurrentUser(response.serverId); + if (sender === currentUser?.username) return; + + const server = useStore + .getState() + .servers.find((s) => s.id === response.serverId); + if (!server) return; + + let channel: Channel | PrivateChat | undefined; + const isChannel = channelName.startsWith("#"); + if (isChannel) { + channel = server.channels.find((c) => c.name === channelName); + } else { + // Private chat + channel = server.privateChats?.find((pc) => pc.username === channelName); + } + + if (!channel) return; + + // Find the message to add reaction to + const messages = getChannelMessages(server.id, channel.id); + const messageIndex = messages.findIndex((m) => m.msgid === replyMessageId); + if (messageIndex === -1) return; + + const message = messages[messageIndex]; + const existingReactionIndex = message.reactions.findIndex( + (r) => r.emoji === emoji && r.userId === sender, + ); + + useStore.setState((state) => { + const updatedMessages = [...messages]; + if (existingReactionIndex === -1) { + // Add new reaction + updatedMessages[messageIndex] = { + ...message, + reactions: [...message.reactions, { emoji, userId: sender }], + }; + } else { + // Remove existing reaction (toggle behavior) + updatedMessages[messageIndex] = { + ...message, + reactions: message.reactions.filter( + (_, i) => i !== existingReactionIndex, + ), + }; + } + + const key = `${server.id}-${channel.id}`; + return { + messages: { + ...state.messages, + [key]: updatedMessages, + }, + }; + }); + } + + // Handle unreacts + if (mtags?.["+draft/unreact"] && mtags["+draft/reply"]) { + const emoji = mtags["+draft/unreact"]; + const replyMessageId = mtags["+draft/reply"]; + + // Skip processing our own unreacts since we handle them optimistically + const currentUser = ircClient.getCurrentUser(response.serverId); + if (sender === currentUser?.username) return; + + const server = useStore + .getState() + .servers.find((s) => s.id === response.serverId); + if (!server) return; + + let channel: Channel | PrivateChat | undefined; + const isChannel = channelName.startsWith("#"); + if (isChannel) { + channel = server.channels.find((c) => c.name === channelName); + } else { + // Private chat + channel = server.privateChats?.find((pc) => pc.username === channelName); + } + + if (!channel) return; + + // Find the message to remove reaction from + const messages = getChannelMessages(server.id, channel.id); + const messageIndex = messages.findIndex((m) => m.msgid === replyMessageId); + if (messageIndex === -1) return; + + const message = messages[messageIndex]; + const existingReactionIndex = message.reactions.findIndex( + (r) => r.emoji === emoji && r.userId === sender, + ); + + // Only remove if the reaction exists + if (existingReactionIndex !== -1) { + useStore.setState((state) => { + const updatedMessages = [...messages]; + updatedMessages[messageIndex] = { + ...message, + reactions: message.reactions.filter( + (_, i) => i !== existingReactionIndex, + ), + }; + + const key = `${server.id}-${channel.id}`; + return { + messages: { + ...state.messages, + [key]: updatedMessages, + }, + }; + }); + } + } + + // Handle link previews + if ( + mtags && + (mtags["obsidianirc/link-preview-title"] || + mtags["obsidianirc/link-preview-snippet"] || + mtags["obsidianirc/link-preview-meta"]) && + mtags["+reply"] + ) { + const replyMessageId = mtags["+reply"]; + + const server = useStore + .getState() + .servers.find((s) => s.id === response.serverId); + if (!server) return; + + let channel: Channel | PrivateChat | undefined; + const isChannel = channelName.startsWith("#"); + if (isChannel) { + channel = server.channels.find((c) => c.name === channelName); + } else { + // Private chat + channel = server.privateChats?.find((pc) => pc.username === channelName); + } + + if (!channel) return; + + // Find the message to add link preview to + const messages = getChannelMessages(server.id, channel.id); + const messageIndex = messages.findIndex((m) => m.msgid === replyMessageId); + if (messageIndex === -1) return; + + const message = messages[messageIndex]; + + // Helper function to unescape IRC tag values + const unescapeTagValue = ( + value: string | undefined, + ): string | undefined => { + if (!value) return undefined; + // IRC tag escaping: \: = ; \s = space \\ = \ \r = CR \n = LF + return value + .replace(/\\s/g, " ") + .replace(/\\:/g, ";") + .replace(/\\r/g, "\r") + .replace(/\\n/g, "\n") + .replace(/\\\\/g, "\\"); + }; + + useStore.setState((state) => { + const updatedMessages = [...messages]; + updatedMessages[messageIndex] = { + ...message, + linkPreviewTitle: unescapeTagValue( + mtags["obsidianirc/link-preview-title"], + ), + linkPreviewSnippet: unescapeTagValue( + mtags["obsidianirc/link-preview-snippet"], + ), + linkPreviewMeta: unescapeTagValue( + mtags["obsidianirc/link-preview-meta"], + ), + }; + + const key = `${server.id}-${channel.id}`; + return { + messages: { + ...state.messages, + [key]: updatedMessages, + }, + }; + }); + } +}); + +ircClient.on("REDACT", ({ serverId, target, msgid, sender }) => { + useStore.setState((state) => { + const server = state.servers.find((s) => s.id === serverId); + if (!server) return {}; + + let channel: Channel | PrivateChat | undefined; + const isChannel = target.startsWith("#"); + if (isChannel) { + channel = server.channels.find((c) => c.name === target); + } else { + // Private chat + channel = server.privateChats?.find((pc) => pc.username === target); + } + + if (!channel) return {}; + + // Find and replace the message with a system message + const messages = getChannelMessages(server.id, channel.id); + const messageIndex = messages.findIndex((m) => m.msgid === msgid); + if (messageIndex === -1) return {}; + + const updatedMessages = [...messages]; + const originalMessage = updatedMessages[messageIndex]; + + // Determine if the sender deleted their own message + const isSender = originalMessage.userId === sender; + const deletionMessage = isSender + ? "This message has been deleted by the sender" + : "This message has been deleted by a member of staff"; + + // Replace the entire message with a system message + updatedMessages[messageIndex] = { + id: originalMessage.id, + msgid: originalMessage.msgid, + content: deletionMessage, + timestamp: originalMessage.timestamp, + userId: "system", + channelId: originalMessage.channelId, + serverId: originalMessage.serverId, + type: "system", + reactions: [], + replyMessage: null, + mentioned: [], + tags: originalMessage.tags, + }; + + const key = `${server.id}-${channel.id}`; + return { + messages: { + ...state.messages, + [key]: updatedMessages, + }, + }; + }); +}); + +// Nick error event handler +ircClient.on("NICK_ERROR", ({ serverId, code, error, nick, message }) => { + // Handle 433 (nickname already in use) with automatic retry + if (code === "433" && nick) { + const newNick = `${nick}_`; + + // Attempt to change to the nick with underscore appended + ircClient.changeNick(serverId, newNick); + + // Add a system message about the retry + const state = useStore.getState(); + const server = state.servers.find((s) => s.id === serverId); + if (server && getCurrentSelection(state).selectedChannelId) { + const channel = server.channels.find( + (c) => c.id === getCurrentSelection(state).selectedChannelId, + ); + if (channel) { + const retryMessage: Message = { + id: uuidv4(), + type: "system", + content: `Nickname '${nick}' already in use, retrying with '${newNick}'`, + timestamp: new Date(), + userId: "system", + channelId: channel.id, + serverId: serverId, + reactions: [], + replyMessage: null, + mentioned: [], + }; + + const key = `${serverId}-${channel.id}`; + useStore.setState((state) => ({ + messages: { + ...state.messages, + [key]: [...(state.messages[key] || []), retryMessage], + }, + })); + } + } + + // Don't show error notification for 433 since we're auto-retrying + return; + } + + // Add to global notifications for visibility (for other error codes) + const state = useStore.getState(); + state.addGlobalNotification({ + type: "fail", + command: "NICK", + code, + message: `${error}: ${message}`, + target: nick, + serverId, + }); + + // Also add a system message to the current channel + const server = state.servers.find((s) => s.id === serverId); + if (server && getCurrentSelection(state).selectedChannelId) { + const channel = server.channels.find( + (c) => c.id === getCurrentSelection(state).selectedChannelId, + ); + if (channel) { + const errorMessage: Message = { + id: uuidv4(), + type: "system", + content: `Nick change failed: ${error} ${nick ? `(${nick})` : ""}`, + timestamp: new Date(), + userId: "system", + channelId: channel.id, + serverId: serverId, + reactions: [], + replyMessage: null, + mentioned: [], + }; + + const key = `${serverId}-${channel.id}`; + useStore.setState((state) => ({ + messages: { + ...state.messages, + [key]: [...(state.messages[key] || []), errorMessage], + }, + })); + } + } +}); + +// Standard reply event handlers +ircClient.on("FAIL", ({ serverId, command, code, target, message }) => { + // Add to global notifications for visibility + const state = useStore.getState(); + state.addGlobalNotification({ + type: "fail", + command, + code, + message, + target, + serverId, + }); +}); + +ircClient.on("WARN", ({ serverId, command, code, target, message }) => { + const state = useStore.getState(); + const server = state.servers.find((s) => s.id === serverId); + if (server) { + // Try to add to the currently selected channel first, fallback to first channel + let channel = server.channels.find( + (c) => c.id === getCurrentSelection(state).selectedChannelId, + ); + if (!channel) { + channel = server.channels[0]; + } + if (channel) { + const notificationMessage: Message = { + id: uuidv4(), + type: "standard-reply", + content: `WARN ${command} ${code}${target ? ` ${target}` : ""}: ${message}`, + timestamp: new Date(), + userId: "system", + channelId: channel.id, + serverId: serverId, + reactions: [], + replyMessage: null, + mentioned: [], + standardReplyType: "WARN", + standardReplyCommand: command, + standardReplyCode: code, + standardReplyTarget: target, + standardReplyMessage: message, + }; + + const key = `${serverId}-${channel.id}`; + useStore.setState((state) => ({ + messages: { + ...state.messages, + [key]: [...(state.messages[key] || []), notificationMessage], + }, + })); + } + } +}); + +ircClient.on("NOTE", ({ serverId, command, code, target, message }) => { + const state = useStore.getState(); + const server = state.servers.find((s) => s.id === serverId); + if (server) { + // Try to add to the currently selected channel first, fallback to first channel + let channel = server.channels.find( + (c) => c.id === getCurrentSelection(state).selectedChannelId, + ); + if (!channel) { + channel = server.channels[0]; + } + if (channel) { + const notificationMessage: Message = { + id: uuidv4(), + type: "standard-reply", + content: `NOTE ${command} ${code}${target ? ` ${target}` : ""}: ${message}`, + timestamp: new Date(), + userId: "system", + channelId: channel.id, + serverId: serverId, + reactions: [], + replyMessage: null, + mentioned: [], + standardReplyType: "NOTE", + standardReplyCommand: command, + standardReplyCode: code, + standardReplyTarget: target, + standardReplyMessage: message, + }; + + const key = `${serverId}-${channel.id}`; + useStore.setState((state) => ({ + messages: { + ...state.messages, + [key]: [...(state.messages[key] || []), notificationMessage], + }, + })); + } + } +}); + +// Account registration event handlers +ircClient.on("REGISTER_SUCCESS", ({ serverId, account, message }) => { + const state = useStore.getState(); + const server = state.servers.find((s) => s.id === serverId); + if (server) { + const channel = server.channels[0]; + if (channel) { + const notificationMessage: Message = { + id: uuidv4(), + type: "system", + content: `Account registration successful for ${account}: ${message}`, + timestamp: new Date(), + userId: "system", + channelId: channel.id, + serverId: serverId, + reactions: [], + replyMessage: null, + mentioned: [], + }; + + const key = `${serverId}-${channel.id}`; + useStore.setState((state) => ({ + messages: { + ...state.messages, + [key]: [...(state.messages[key] || []), notificationMessage], + }, + })); + } + } +}); + +ircClient.on( + "REGISTER_VERIFICATION_REQUIRED", + ({ serverId, account, message }) => { + const state = useStore.getState(); + const server = state.servers.find((s) => s.id === serverId); + if (server) { + const channel = server.channels[0]; + if (channel) { + const notificationMessage: Message = { + id: uuidv4(), + type: "system", + content: `Account registration for ${account} requires verification: ${message}`, + timestamp: new Date(), + userId: "system", + channelId: channel.id, + serverId: serverId, + reactions: [], + replyMessage: null, + mentioned: [], + }; + + const key = `${serverId}-${channel.id}`; + useStore.setState((state) => ({ + messages: { + ...state.messages, + [key]: [...(state.messages[key] || []), notificationMessage], + }, + })); + } + } + }, +); + +ircClient.on("VERIFY_SUCCESS", ({ serverId, account, message }) => { + const state = useStore.getState(); + const server = state.servers.find((s) => s.id === serverId); + if (server) { + const channel = server.channels[0]; + if (channel) { + const notificationMessage: Message = { + id: uuidv4(), + type: "system", + content: `Account verification successful for ${account}: ${message}`, + timestamp: new Date(), + userId: "system", + channelId: channel.id, + serverId: serverId, + reactions: [], + replyMessage: null, + mentioned: [], + }; + + const key = `${serverId}-${channel.id}`; + useStore.setState((state) => ({ + messages: { + ...state.messages, + [key]: [...(state.messages[key] || []), notificationMessage], + }, + })); + } + } +}); + +// Metadata event handlers +ircClient.on("METADATA", ({ serverId, target, key, visibility, value }) => { + useStore.setState((state) => { + // Resolve the target - if it's "*", it refers to the current user + const serverCurrentUser = ircClient.getCurrentUser(serverId); + const resolvedTarget = + target === "*" + ? ircClient.getNick(serverId) || serverCurrentUser?.username || target + : target.split("!")[0]; // Extract nickname from mask + + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + // Update metadata for users in channels + const updatedChannels = server.channels.map((channel) => { + const updatedUsers = channel.users.map((user) => { + if (user.username === resolvedTarget) { + const metadata = { ...(user.metadata || {}) }; + if (value) { + metadata[key] = { value, visibility }; + } else { + delete metadata[key]; + } + console.log( + `[METADATA] Updated user ${resolvedTarget} in channel ${channel.name} with ${key}=${value}`, + ); + return { ...user, metadata }; + } + return user; + }); + + // Update metadata for the channel itself if target matches channel name + const channelMetadata = { ...(channel.metadata || {}) }; + if (resolvedTarget === channel.name) { + if (value) { + channelMetadata[key] = { value, visibility }; + } else { + delete channelMetadata[key]; + } + } + + return { + ...channel, + users: updatedUsers, + metadata: channelMetadata, + }; + }); + + // Update metadata for the server itself if target is server + const updatedMetadata = { ...(server.metadata || {}) }; + if (resolvedTarget === server.name) { + if (value) { + updatedMetadata[key] = { value, visibility }; + } else { + delete updatedMetadata[key]; + } + } + + // Update metadata for private chat users + const updatedPrivateChats = server.privateChats?.map((pm) => { + if (pm.username.toLowerCase() === resolvedTarget.toLowerCase()) { + // We don't store metadata directly on PrivateChat, + // but we can use this to trigger UI updates + // The avatar/metadata will be looked up from savedMetadata + return { ...pm }; + } + return pm; + }); + + return { + ...server, + channels: updatedChannels, + metadata: updatedMetadata, + privateChats: updatedPrivateChats, + }; + } + return server; + }); + + // Update current user metadata if the target matches any connected user + let updatedCurrentUser = state.currentUser; + const currentUserForServer = ircClient.getCurrentUser(serverId); + + // Check if this metadata is for the current user on this server + if ( + currentUserForServer && + currentUserForServer.username === resolvedTarget + ) { + // If this is the first time setting current user or it's for the selected server, update global state + if (!updatedCurrentUser || state.ui.selectedServerId === serverId) { + const metadata = { ...(currentUserForServer.metadata || {}) }; + if (value) { + metadata[key] = { value, visibility }; + } else { + delete metadata[key]; + } + // Preserve existing isIrcOp and modes when updating currentUser + updatedCurrentUser = { + ...currentUserForServer, + metadata, + isIrcOp: state.currentUser?.isIrcOp, + modes: state.currentUser?.modes, + }; + } + // If there's already a current user but it's for a different server, + // still update if this is the selected server or if there's no current user + else if ( + state.currentUser && + state.currentUser.username === resolvedTarget + ) { + const metadata = { ...(state.currentUser.metadata || {}) }; + if (value) { + metadata[key] = { value, visibility }; + } else { + delete metadata[key]; + } + // Preserve existing isIrcOp and modes when updating currentUser + updatedCurrentUser = { + ...state.currentUser, + metadata, + isIrcOp: state.currentUser.isIrcOp, + modes: state.currentUser.modes, + }; + } + } + + // Save metadata to localStorage + const savedMetadata = loadSavedMetadata(); + if (!savedMetadata[serverId]) { + savedMetadata[serverId] = {}; + } + if (!savedMetadata[serverId][resolvedTarget]) { + savedMetadata[serverId][resolvedTarget] = {}; + } + if (value) { + savedMetadata[serverId][resolvedTarget][key] = { value, visibility }; + } else { + delete savedMetadata[serverId][resolvedTarget][key]; + } + saveMetadataToLocalStorage(savedMetadata); + + // Update channel metadata cache if this is for a channel + if (resolvedTarget.startsWith("#")) { + const cache = state.channelMetadataCache[serverId] || {}; + const channelCache = cache[resolvedTarget] || { fetchedAt: Date.now() }; + + if (key === "avatar") { + channelCache.avatar = value || undefined; + } else if (key === "display-name") { + channelCache.displayName = value || undefined; + } + + channelCache.fetchedAt = Date.now(); + + const updatedCache = { + ...state.channelMetadataCache, + [serverId]: { + ...cache, + [resolvedTarget]: channelCache, + }, + }; + + // Remove from fetch queue + const queue = state.channelMetadataFetchQueue[serverId]; + if (queue) { + const newQueue = new Set(queue); + newQueue.delete(resolvedTarget); + + return { + servers: updatedServers, + currentUser: updatedCurrentUser, + channelMetadataCache: updatedCache, + channelMetadataFetchQueue: { + ...state.channelMetadataFetchQueue, + [serverId]: newQueue, + }, + }; + } + + return { + servers: updatedServers, + currentUser: updatedCurrentUser, + channelMetadataCache: updatedCache, + }; + } + + return { + servers: updatedServers, + currentUser: updatedCurrentUser, + metadataChangeCounter: state.metadataChangeCounter + 1, + }; + }); +}); + +ircClient.on( + "METADATA_KEYVALUE", + ({ serverId, target, key, visibility, value }) => { + const state = useStore.getState(); + const isFetchingOwn = state.metadataFetchInProgress[serverId]; + + // Handle individual key-value responses (similar to METADATA) + useStore.setState((state) => { + // Resolve the target - if it's "*", it refers to the current user + const resolvedTarget = + target === "*" + ? ircClient.getNick(serverId) || state.currentUser?.username || target + : target.split("!")[0]; // Extract nickname from mask + + // If we're fetching our own metadata, update saved values + if (isFetchingOwn && target === "*") { + const savedMetadata = loadSavedMetadata(); + if (!savedMetadata[serverId]) { + savedMetadata[serverId] = {}; + } + if (!savedMetadata[serverId][resolvedTarget]) { + savedMetadata[serverId][resolvedTarget] = {}; + } + // Only overwrite saved value with server value if server actually has a value + // Empty/null values from server mean "not set", so keep our local value + if (value !== null && value !== undefined && value !== "") { + savedMetadata[serverId][resolvedTarget][key] = { value, visibility }; + saveMetadataToLocalStorage(savedMetadata); + } + // If server has the key but no value, and we have a local value, we'll send ours later + } + + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + // Update metadata for users in channels + const updatedChannels = server.channels.map((channel) => { + const userInChannel = channel.users.find( + (u) => u.username === resolvedTarget, + ); + if (userInChannel) { + } + + const updatedUsers = channel.users.map((user) => { + if (user.username === resolvedTarget) { + const metadata = { ...(user.metadata || {}) }; + // Only update metadata if value is present (not empty/null) + if (value !== null && value !== undefined && value !== "") { + metadata[key] = { value, visibility }; + } else { + // If server sends empty/null, remove the key (it's not set on server) + // But only if we're not in fetch mode - during fetch, keep local values + if (!isFetchingOwn || target !== "*") { + delete metadata[key]; + } + } + return { ...user, metadata }; + } + return user; + }); + + // Update metadata for the channel itself if target matches channel name + let updatedChannelMetadata = channel.metadata || {}; + if (resolvedTarget === channel.name) { + // Only update THIS channel's metadata if the target matches exactly + updatedChannelMetadata = { ...updatedChannelMetadata }; + if (value !== null && value !== undefined && value !== "") { + updatedChannelMetadata[key] = { value, visibility }; + } else { + delete updatedChannelMetadata[key]; + } + } + + return { + ...channel, + users: updatedUsers, + metadata: updatedChannelMetadata, + }; + }); + + return { + ...server, + channels: updatedChannels, + }; + } + return server; + }); + + // Update current user metadata + let updatedCurrentUser = state.currentUser; + if (state.currentUser?.username === resolvedTarget) { + const metadata = { ...(state.currentUser.metadata || {}) }; + // Only update metadata if value is present (not empty/null) + if (value !== null && value !== undefined && value !== "") { + metadata[key] = { value, visibility }; + } else { + // If server sends empty/null, remove the key (it's not set on server) + // But only if we're not in fetch mode - during fetch, keep local values + if (!isFetchingOwn || target !== "*") { + delete metadata[key]; + } + } + updatedCurrentUser = { ...state.currentUser, metadata }; + console.log( + `[METADATA_KEYVALUE] Updated current user ${resolvedTarget} with ${key}=${value}`, + ); + } + + // Save metadata to localStorage (unless we're in fetch mode - already saved above) + if (!isFetchingOwn || target !== "*") { + const savedMetadata = loadSavedMetadata(); + if (!savedMetadata[serverId]) { + savedMetadata[serverId] = {}; + } + if (!savedMetadata[serverId][resolvedTarget]) { + savedMetadata[serverId][resolvedTarget] = {}; + } + savedMetadata[serverId][resolvedTarget][key] = { value, visibility }; + saveMetadataToLocalStorage(savedMetadata); + } + + // Update channel metadata cache if this is for a channel + if (resolvedTarget.startsWith("#")) { + const cache = state.channelMetadataCache[serverId] || {}; + const channelCache = cache[resolvedTarget] || { fetchedAt: Date.now() }; + + if (key === "avatar" && value) { + channelCache.avatar = value; + } else if (key === "display-name" && value) { + channelCache.displayName = value; + } + + channelCache.fetchedAt = Date.now(); + + const updatedCache = { + ...state.channelMetadataCache, + [serverId]: { + ...cache, + [resolvedTarget]: channelCache, + }, + }; + + // Remove from fetch queue + const queue = state.channelMetadataFetchQueue[serverId]; + if (queue) { + const newQueue = new Set(queue); + newQueue.delete(resolvedTarget); + + return { + servers: updatedServers, + currentUser: updatedCurrentUser, + channelMetadataCache: updatedCache, + channelMetadataFetchQueue: { + ...state.channelMetadataFetchQueue, + [serverId]: newQueue, + }, + metadataChangeCounter: state.metadataChangeCounter + 1, + }; + } + + return { + servers: updatedServers, + currentUser: updatedCurrentUser, + channelMetadataCache: updatedCache, + metadataChangeCounter: state.metadataChangeCounter + 1, + }; + } + + return { + servers: updatedServers, + currentUser: updatedCurrentUser, + metadataChangeCounter: state.metadataChangeCounter + 1, + }; + }); + }, +); + +ircClient.on("METADATA_KEYNOTSET", ({ serverId, target, key }) => { + const state = useStore.getState(); + const isFetchingOwn = state.metadataFetchInProgress[serverId]; + + // Resolve the target - if it's "*", it refers to the current user + const resolvedTarget = + target === "*" + ? ircClient.getNick(serverId) || state.currentUser?.username || target + : target.split("!")[0]; // Extract nickname from mask + + // If we're fetching our own metadata and the key is not set, delete it from saved values + if (isFetchingOwn && target === "*") { + const savedMetadata = loadSavedMetadata(); + if (savedMetadata[serverId]?.[resolvedTarget]?.[key]) { + delete savedMetadata[serverId][resolvedTarget][key]; + saveMetadataToLocalStorage(savedMetadata); + } + } + + // Handle key not set responses + useStore.setState((state) => { + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + // Remove metadata for users in channels + const updatedChannels = server.channels.map((channel) => { + const updatedUsers = channel.users.map((user) => { + if (user.username === resolvedTarget) { + const metadata = user.metadata || {}; + delete metadata[key]; + return { ...user, metadata }; + } + return user; + }); + + // Remove metadata for the channel itself if target matches channel name + const channelMetadata = channel.metadata || {}; + if ( + resolvedTarget === channel.name || + resolvedTarget.startsWith("#") + ) { + delete channelMetadata[key]; + } + + return { ...channel, users: updatedUsers, metadata: channelMetadata }; + }); + return { ...server, channels: updatedChannels }; + } + return server; + }); + + return { servers: updatedServers }; + }); +}); + +ircClient.on("METADATA_SUBOK", ({ serverId, keys }) => { + console.log( + `[METADATA_SUBOK] Successfully subscribed to keys for server ${serverId}:`, + keys, + ); + // Update subscriptions + useStore.setState((state) => { + const currentSubs = state.metadataSubscriptions[serverId] || []; + const newSubs = [...new Set([...currentSubs, ...keys])]; + return { + metadataSubscriptions: { + ...state.metadataSubscriptions, + [serverId]: newSubs, + }, + }; + }); +}); + +ircClient.on("METADATA_UNSUBOK", ({ serverId, keys }) => { + // Update subscriptions + useStore.setState((state) => { + const currentSubs = state.metadataSubscriptions[serverId] || []; + const newSubs = currentSubs.filter((k) => !keys.includes(k)); + return { + metadataSubscriptions: { + ...state.metadataSubscriptions, + [serverId]: newSubs, + }, + }; + }); +}); + +ircClient.on("METADATA_SUBS", ({ serverId, keys }) => { + // Set all subscriptions + useStore.setState((state) => ({ + metadataSubscriptions: { + ...state.metadataSubscriptions, + [serverId]: keys, + }, + })); +}); + +ircClient.on("BATCH_START", ({ serverId, batchId, type }) => { + // Start a batch + useStore.setState((state) => ({ + metadataBatches: { + ...state.metadataBatches, + [batchId]: { type, messages: [] }, + }, + })); +}); + +ircClient.on("BATCH_END", ({ serverId, batchId }) => { + // End a batch - process all messages in the batch + useStore.setState((state) => { + const batch = state.metadataBatches[batchId]; + if (batch) { + // Process batch messages (they should have been collected during the batch) + // For metadata batches, the individual METADATA_KEYVALUE events should have updated the state + } + const { [batchId]: _, ...remainingBatches } = state.metadataBatches; + return { + metadataBatches: remainingBatches, + }; + }); +}); + +// Helper function to process netsplit batches +function processBatchedNetsplit( + serverId: string, + batchId: string, + batch: BatchInfo, +) { + const store = useStore.getState(); + const batch_info = store.activeBatches[serverId]?.[batchId]; + if (!batch_info) return; + + const quitEvents = batch_info.events; + const [server1, server2] = batch_info.parameters || ["*.net", "*.split"]; + + // Create a single netsplit message + const netsplitMessage = { + id: `netsplit-${batchId}`, + content: "Oops! The net split! ⚠️", + timestamp: new Date(), + userId: "system", + channelId: "", // Will be set per channel + serverId, + type: "netsplit" as const, + batchId, + quitUsers: quitEvents.map((e) => e.data.username), + server1, + server2, + reactions: [], + replyMessage: null, + mentioned: [], + }; + + // Group affected channels and add the netsplit message to each + const affectedChannels = new Set(); + + // Process each quit event to remove users and track affected channels + quitEvents.forEach((event) => { + const { username } = event.data; + + // Find which channels this user was in and remove them + useStore.setState((state) => { + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + const updatedChannels = server.channels.map((channel) => { + const userIndex = channel.users.findIndex( + (u) => u.username === username, + ); + if (userIndex !== -1) { + affectedChannels.add(channel.id); + // Remove the user from the channel + const updatedUsers = channel.users.filter( + (u) => u.username !== username, + ); + return { ...channel, users: updatedUsers }; + } + return channel; + }); + return { ...server, channels: updatedChannels }; + } + return server; + }); + return { servers: updatedServers }; + }); + }); + + // Add netsplit message to each affected channel + affectedChannels.forEach((channelId) => { + const channelMessage = { ...netsplitMessage, channelId }; + useStore.getState().addMessage(channelMessage); + }); +} + +// Helper function to process netjoin batches +function processBatchedNetjoin( + serverId: string, + batchId: string, + batch: BatchInfo, +) { + const store = useStore.getState(); + const batch_info = store.activeBatches[serverId]?.[batchId]; + if (!batch_info) return; + + const joinEvents = batch_info.events; + const [server1, server2] = batch_info.parameters || ["*.net", "*.join"]; + + // Process each join event normally first + joinEvents.forEach((event) => { + // Re-trigger the JOIN event to add users back + if (event.type === "JOIN") { + ircClient.triggerEvent("JOIN", event.data); + } + }); + + // Find and update any existing netsplit messages to show rejoin + useStore.setState((state) => { + const updatedMessages = { ...state.messages }; + + Object.keys(updatedMessages).forEach((channelKey) => { + const messages = updatedMessages[channelKey]; + const updatedChannelMessages = messages.map((message) => { + if ( + message.type === "netsplit" && + message.serverId === serverId && + message.server1 === server1 && + message.server2 === server2 + ) { + // Update the netsplit message to show rejoin + return { + ...message, + content: "The network split and rejoined. ✅", + type: "netjoin" as const, + }; + } + return message; + }); + updatedMessages[channelKey] = updatedChannelMessages; + }); + + return { messages: updatedMessages }; + }); +} + +// Handle chathistory loading state +ircClient.on("CHATHISTORY_LOADING", ({ serverId, channelName, isLoading }) => { + useStore.setState((state) => { + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + const updatedChannels = server.channels.map((channel) => { + if (channel.name.toLowerCase() === channelName.toLowerCase()) { + const updatedChannel = { ...channel, isLoadingHistory: isLoading }; + + // If loading just completed and we need to send WHO, do it now + if (!isLoading && channel.needsWhoRequest) { + // Send WHO request now that CHATHISTORY is done + ircClient.sendRaw(serverId, `WHO ${channelName} %cuhnfaro`); + + // Request channel metadata if server supports it + if (serverSupportsMetadata(serverId)) { + ircClient.metadataGet(serverId, channelName, [ + "avatar", + "display-name", + ]); + } + + // Clear the flag + updatedChannel.needsWhoRequest = false; + } + + return updatedChannel; + } + return channel; + }); + return { ...server, channels: updatedChannels }; + } + return server; + }); + return { servers: updatedServers }; + }); +}); + +ircClient.on( + "METADATA_FAIL", + ({ serverId, subcommand, code, target, key, retryAfter }) => { + // Handle metadata failures + console.error(`Metadata ${subcommand} failed: ${code}`, { + target, + key, + retryAfter, + }); + // Could show user notifications here + }, +); + +// Load saved servers on store initialization + +// If default server is available, select it +if (__DEFAULT_IRC_SERVER__) { +} + +ircClient.on("RENAME", ({ serverId, oldName, newName, reason, user }) => { + useStore.setState((state) => { + const server = state.servers.find((s) => s.id === serverId); + if (!server) return {}; + + const channel = server.channels.find((c) => c.name === oldName); + if (!channel) return {}; + + channel.name = newName; + + const renameMessage: Message = { + id: `rename-${Date.now()}`, + content: `Channel has been renamed from ${oldName} to ${newName} by ${user}${reason ? ` (${reason})` : ""}`, + timestamp: new Date(), + userId: "system", + channelId: channel.id, + serverId, + type: "system", + reactions: [], + replyMessage: null, + mentioned: [], + }; + + const channelKey = `${serverId}-${channel.id}`; + const currentMessages = state.messages[channelKey] || []; + return { + messages: { + ...state.messages, + [channelKey]: [...currentMessages, renameMessage], + }, + }; + }); +}); + +ircClient.on("SETNAME", ({ serverId, user, realname }) => { + useStore.setState((state) => { + const server = state.servers.find((s) => s.id === serverId); + if (!server) return {}; + + // Update current user if it's us + if (user === state.currentUser?.username) { + return { + currentUser: { + ...state.currentUser, + realname: realname, + }, + }; + } + + // Update in channels + const updatedServers = state.servers.map((s) => { + if (s.id === serverId) { + const updatedChannels = s.channels.map((c) => ({ + ...c, + users: c.users.map((u) => + u.username === user ? { ...u, realname: realname } : u, + ), + })); + return { ...s, channels: updatedChannels }; + } + return s; + }); + + return { servers: updatedServers }; + }); +}); + +// MONITOR event handlers +ircClient.on("MONONLINE", ({ serverId, targets }) => { + useStore.setState((state) => { + const server = state.servers.find((s) => s.id === serverId); + if (!server) return {}; + + // Update private chats to mark users as online + const updatedServers = state.servers.map((s) => { + if (s.id === serverId) { + const updatedPrivateChats = s.privateChats?.map((pm) => { + const target = targets.find( + (t) => t.nick.toLowerCase() === pm.username.toLowerCase(), + ); + if (target) { + return { ...pm, isOnline: true, isAway: false }; + } + return pm; + }); + return { ...s, privateChats: updatedPrivateChats }; + } + return s; + }); + + return { servers: updatedServers }; + }); +}); + +ircClient.on("MONOFFLINE", ({ serverId, targets }) => { + useStore.setState((state) => { + const server = state.servers.find((s) => s.id === serverId); + if (!server) return {}; + + // Update private chats to mark users as offline + const updatedServers = state.servers.map((s) => { + if (s.id === serverId) { + const updatedPrivateChats = s.privateChats?.map((pm) => { + const isOffline = targets.some( + (t) => t.toLowerCase() === pm.username.toLowerCase(), + ); + if (isOffline) { + return { ...pm, isOnline: false, isAway: false }; + } + return pm; + }); + return { ...s, privateChats: updatedPrivateChats }; + } + return s; + }); + + return { servers: updatedServers }; + }); +}); + +// Handle AWAY notifications for monitored users (extended-monitor) +ircClient.on("AWAY", ({ serverId, username, awayMessage }) => { + useStore.setState((state) => { + const server = state.servers.find((s) => s.id === serverId); + if (!server) return {}; + + // Update private chats for monitored users + const updatedServers = state.servers.map((s) => { + if (s.id === serverId) { + const updatedPrivateChats = s.privateChats?.map((pm) => { + if (pm.username.toLowerCase() === username.toLowerCase()) { + return { + ...pm, + isAway: awayMessage !== undefined && awayMessage !== null, + awayMessage: awayMessage || undefined, + isOnline: true, // They're still online, just away + }; + } + return pm; + }); + return { ...s, privateChats: updatedPrivateChats }; + } + return s; + }); + + return { servers: updatedServers }; + }); +}); + +// Handle RPL_AWAY (301) from WHOIS responses +ircClient.on("RPL_AWAY", ({ serverId, nick, awayMessage }) => { + useStore.setState((state) => { + const updatedServers = state.servers.map((s) => { + if (s.id === serverId) { + const updatedPrivateChats = s.privateChats?.map((pm) => { + if (pm.username.toLowerCase() === nick.toLowerCase()) { + return { + ...pm, + awayMessage: awayMessage || undefined, + isAway: true, + }; + } + return pm; + }); + return { ...s, privateChats: updatedPrivateChats }; + } + return s; + }); + + return { servers: updatedServers }; + }); +}); + +// WHO reply handler - for standard WHO responses (352) when server doesn't support WHOX +ircClient.on( + "WHO_REPLY", + ({ + serverId, + channel, + username, + host, + server, + nick, + flags, + hopcount, + realname, + }) => { + const state = useStore.getState(); + const serverData = state.servers.find((s) => s.id === serverId); + if (!serverData) return; + + // Parse away status from flags (e.g., "H@" means here and operator, "G" means gone/away) + let isAway = false; + if (flags) { + // First character indicates here (H) or gone/away (G) + if (flags[0] === "G") { + isAway = true; + } else if (flags[0] === "H") { + isAway = false; + } + } + + // If channel is "*", this is a user-specific WHO query (e.g., "WHO username") + // Update private chats only in this case + if (channel === "*") { + useStore.setState((state) => { + const updatedServers = state.servers.map((s) => { + if (s.id === serverId) { + const updatedPrivateChats = s.privateChats?.map((pm) => { + if (pm.username.toLowerCase() === nick.toLowerCase()) { + // If user is away and this is a pinned PM, send WHOIS to get away message + if (isAway && pm.isPinned) { + setTimeout(() => { + ircClient.sendRaw(serverId, `WHOIS ${nick}`); + }, 100); + } + + return { + ...pm, + isOnline: true, + isAway: isAway, + }; + } + return pm; + }); + + return { + ...s, + privateChats: updatedPrivateChats, + }; + } + return s; + }); + + return { servers: updatedServers }; + }); + return; // Don't process channel user list for user-specific queries + } + + // Find the channel this WHO reply belongs to + const channelData = serverData.channels.find((c) => c.name === channel); + if (!channelData) { + return; + } + + // Parse channel status from flags (e.g., "@" means operator) + let channelStatus = ""; + + if (flags) { + // Extract channel status prefixes from flags + const statusChars = flags.match(/[~&@%+]/g); + if (statusChars) { + channelStatus = statusChars.join(""); + } + } + + // Create user object from WHO data with proper User type + const user: User = { + id: nick, + username: nick, + hostname: host, // Store the hostname from WHO reply + realname: realname, // Store the realname/gecos from WHO reply + avatar: undefined, + isOnline: true, + isAway: isAway, + isBot: false, + isIrcOp: flags ? flags.includes("*") : false, // Check for IRC operator flag + status: channelStatus, // Set the channel status here + metadata: {}, + }; + // Check for bot flags if bot mode is enabled + if (serverData.botMode) { + const botFlag = serverData.botMode; + const isBot = flags.includes(botFlag); + + if (isBot) { + user.isBot = true; + user.metadata = { + bot: { value: "true", visibility: "public" }, + }; + } + } + + // Load saved metadata for this user from localStorage + const savedMetadata = loadSavedMetadata(); + if (savedMetadata[serverId]?.[nick]) { + user.metadata = { + ...user.metadata, + ...savedMetadata[serverId][nick], + }; + } + + // Update the channel's user list with this user + useStore.setState((state) => { + const updatedServers = state.servers.map((s) => { + if (s.id === serverId) { + // Update channels + const updatedChannels = s.channels.map((ch) => { + if (ch.name === channel) { + // Check if user already exists in the list + const existingUserIndex = ch.users.findIndex( + (u) => u.username === nick, + ); + + if (existingUserIndex !== -1) { + // Update existing user + const updatedUsers = [...ch.users]; + updatedUsers[existingUserIndex] = { + ...updatedUsers[existingUserIndex], + ...user, + metadata: { + ...updatedUsers[existingUserIndex].metadata, + ...user.metadata, + }, + }; + return { ...ch, users: updatedUsers }; + } + // Add new user + return { ...ch, users: [...ch.users, user] }; + } + return ch; + }); + + // Also update private chats if this user has a PM tab open + const updatedPrivateChats = s.privateChats.map((pm) => { + if (pm.username.toLowerCase() === nick.toLowerCase()) { + // Update the PM tab with realname from WHO + return { + ...pm, + realname: realname, + }; + } + return pm; + }); + + return { + ...s, + channels: updatedChannels, + privateChats: updatedPrivateChats, + }; + } + return s; + }); + + return { servers: updatedServers }; + }); + }, +); + +ircClient.on("WHO_END", ({ serverId, mask }) => { + // When WHO list is complete for a channel, request metadata for all users + // This ensures we get current metadata for users who were already in the channel + const state = useStore.getState(); + const serverData = state.servers.find((s) => s.id === serverId); + if (!serverData) return; + + // Find the channel (mask should be the channel name) + const channelData = serverData.channels.find((c) => c.name === mask); + + if (channelData) { + // This was a WHO for a channel + // Only request metadata if server supports it + if (serverSupportsMetadata(serverId)) { + // Request metadata for all users in the channel + channelData.users.forEach((user) => { + // Only request if we don't already have metadata for this user + const hasMetadata = + user.metadata && Object.keys(user.metadata).length > 0; + if (!hasMetadata) { + useStore.getState().metadataList(serverId, user.username); + } + }); + } + } else { + // This might be a WHO for an individual user (private chat) + // If we got no WHO_REPLY before this WHO_END, the user is offline + const privateChat = serverData.privateChats?.find( + (pm) => pm.username.toLowerCase() === mask.toLowerCase(), + ); + + if (privateChat) { + // Check if we got a WHO_REPLY for this user by checking their online status + // If they're still marked as offline after WHO_END, they're truly offline + useStore.setState((state) => { + const updatedServers = state.servers.map((s) => { + if (s.id === serverId) { + const updatedPrivateChats = s.privateChats?.map((pm) => { + if (pm.username.toLowerCase() === mask.toLowerCase()) { + // If no WHO_REPLY was received, isOnline would still be false + // Keep it that way and mark as not away + if (!pm.isOnline) { + return { ...pm, isOnline: false, isAway: false }; + } + } + return pm; + }); + return { ...s, privateChats: updatedPrivateChats }; + } + return s; + }); + return { servers: updatedServers }; + }); + } + } +}); + +// WHOX reply handler - for WHO responses with account information +ircClient.on( + "WHOX_REPLY", + ({ + serverId, + channel, + username, + host, + nick, + account, + flags, + realname, + isAway, + opLevel, + }) => { + const state = useStore.getState(); + const serverData = state.servers.find((s) => s.id === serverId); + if (!serverData) return; + + // Determine flags once + const isBotFromFlags = flags.includes("B"); + const isIrcOpFromFlags = flags.includes("*"); + const accountValue = account === "0" ? undefined : account; + + useStore.setState((state) => { + const updatedServers = state.servers.map((s) => { + if (s.id === serverId) { + let updatedPrivateChats = s.privateChats || []; + let updatedChannels = s.channels; + + // Update private chat with account and realname information + const privateChatIndex = updatedPrivateChats.findIndex( + (pm) => pm.username.toLowerCase() === nick.toLowerCase(), + ); + if (privateChatIndex !== -1) { + const existingPm = updatedPrivateChats[privateChatIndex]; + const isBot = existingPm.isBot || isBotFromFlags; + + // Only update if something actually changed + if ( + existingPm.realname !== realname || + existingPm.account !== accountValue || + existingPm.isOnline !== true || + existingPm.isAway !== isAway || + existingPm.isBot !== isBot || + existingPm.isIrcOp !== isIrcOpFromFlags + ) { + updatedPrivateChats = [...updatedPrivateChats]; + updatedPrivateChats[privateChatIndex] = { + ...existingPm, + realname: realname, + account: accountValue, + isOnline: true, + isAway: isAway, + isBot: isBot, + isIrcOp: isIrcOpFromFlags, + }; + } + } + + // Update/add channel users from WHOX response + updatedChannels = updatedChannels.map((ch) => { + // Only update the specific channel from the WHOX response + if (ch.name === channel) { + // Check if user already exists in this channel + const existingUserIndex = ch.users.findIndex( + (user) => user.username.toLowerCase() === nick.toLowerCase(), + ); + + if (existingUserIndex !== -1) { + // Update existing user + const existingUser = ch.users[existingUserIndex]; + const isBot = existingUser.isBot || isBotFromFlags; + + // Only update if something actually changed + if ( + existingUser.hostname !== host || + existingUser.realname !== realname || + existingUser.account !== accountValue || + existingUser.isAway !== isAway || + existingUser.isBot !== isBot || + existingUser.isIrcOp !== isIrcOpFromFlags || + existingUser.status !== (opLevel || existingUser.status) + ) { + const updatedUsers = [...ch.users]; + updatedUsers[existingUserIndex] = { + ...existingUser, + hostname: host, + realname: realname, + account: accountValue, + isAway: isAway, + isBot: isBot, + isIrcOp: isIrcOpFromFlags, + status: opLevel || existingUser.status, + }; + return { ...ch, users: updatedUsers }; + } + } else { + // Add new user to channel + const newUser: User = { + id: `${nick}-${serverId}`, + username: nick, + hostname: host, + realname: realname, + account: accountValue, + isOnline: true, + isAway: isAway, + isBot: isBotFromFlags, + isIrcOp: isIrcOpFromFlags, + status: opLevel, + metadata: {}, + }; + return { ...ch, users: [...ch.users, newUser] }; + } + } + return ch; + }); + + return { + ...s, + privateChats: updatedPrivateChats, + channels: updatedChannels, + }; + } + return s; + }); + return { servers: updatedServers }; + }); + + // Update currentUser if this WHOX reply is for the current user + // NOTE: We parse IRC op flags from WHO responses about ourselves for logging, + // but don't update our own state from WHO replies - that should come from MODE events + const currentUser = state.currentUser; + if ( + currentUser && + currentUser.username.toLowerCase() === nick.toLowerCase() + ) { + // Don't update currentUser from WHO replies - only from authoritative MODE events + return; + } + + // If user is away and we have a pinned PM with them, send WHOIS to get away message + const privateChat = serverData.privateChats?.find( + (pm) => pm.username.toLowerCase() === nick.toLowerCase() && pm.isPinned, + ); + + if (isAway && privateChat) { + // Send WHOIS to get the away message + setTimeout(() => { + ircClient.sendRaw(serverId, `WHOIS ${nick}`); + }, 50); + } + }, +); + +ircClient.on("WHOIS_BOT", ({ serverId, target }) => { + // Update user objects in channels + useStore.setState((state) => { + const updatedServers = state.servers.map((s) => { + if (s.id === serverId) { + const updatedChannels = s.channels.map((channel) => { + const updatedUsers = channel.users.map((user) => { + if (user.username === target) { + return { + ...user, + isBot: true, // Set the WHOIS-detected bot flag + metadata: { + ...user.metadata, + // Keep bot metadata if it exists, but don't require it for display + bot: user.metadata?.bot || { + value: "true", + visibility: "public", + }, + }, + }; + } + return user; + }); + return { ...channel, users: updatedUsers }; + }); + return { ...s, channels: updatedChannels }; + } + return s; + }); + return { servers: updatedServers }; + }); +}); + +// AWAY event handler for away-notify extension +ircClient.on("AWAY", ({ serverId, username, awayMessage }) => { + useStore.setState((state) => { + const updatedServers = state.servers.map((s) => { + if (s.id === serverId) { + // Update user in all channels they're in + const updatedChannels = s.channels.map((channel) => { + const updatedUsers = channel.users.map((user) => { + if (user.username === username) { + return { + ...user, + isAway: !!awayMessage, + awayMessage: awayMessage || undefined, + }; + } + return user; + }); + return { ...channel, users: updatedUsers }; + }); + return { ...s, channels: updatedChannels }; + } + return s; + }); + + // Update current user if this is us + let updatedCurrentUser = state.currentUser; + if (state.currentUser?.username === username) { + updatedCurrentUser = { + ...state.currentUser, + isAway: !!awayMessage, + awayMessage: awayMessage || undefined, + }; + } + + return { servers: updatedServers, currentUser: updatedCurrentUser }; + }); +}); + +// Handle CHGHOST - update user hostname when it changes +ircClient.on("CHGHOST", ({ serverId, username, newUser, newHost }) => { + useStore.setState((state) => { + const updatedServers = state.servers.map((s) => { + if (s.id === serverId) { + // Update user in all channels they're in + const updatedChannels = s.channels.map((channel) => { + const updatedUsers = channel.users.map((user) => { + if (user.username === username) { + return { + ...user, + hostname: newHost, + }; + } + return user; + }); + return { ...channel, users: updatedUsers }; + }); + + // Update user in server-level users list if present + const updatedServerUsers = s.users.map((user) => { + if (user.username === username) { + return { + ...user, + hostname: newHost, + }; + } + return user; + }); + + return { ...s, channels: updatedChannels, users: updatedServerUsers }; + } + return s; + }); + + // Update current user if this is us + let updatedCurrentUser = state.currentUser; + if (state.currentUser?.username === username) { + updatedCurrentUser = { + ...state.currentUser, + hostname: newHost, + }; + } + + return { servers: updatedServers, currentUser: updatedCurrentUser }; + }); +}); + +// Handle 306 numeric - we are now marked as away +ircClient.on("RPL_NOWAWAY", ({ serverId, message }) => { + useStore.setState((state) => { + const updatedServers = state.servers.map((s) => { + if (s.id === serverId) { + return { + ...s, + isAway: true, + awayMessage: message, + }; + } + return s; + }); + + // Update current user if this is the selected server + let updatedCurrentUser = state.currentUser; + if (state.ui.selectedServerId === serverId && state.currentUser) { + updatedCurrentUser = { + ...state.currentUser, + isAway: true, + awayMessage: message, + }; + } + + return { servers: updatedServers, currentUser: updatedCurrentUser }; + }); +}); + +// Handle 305 numeric - we are no longer marked as away +ircClient.on("RPL_UNAWAY", ({ serverId, message }) => { + useStore.setState((state) => { + const updatedServers = state.servers.map((s) => { + if (s.id === serverId) { + return { + ...s, + isAway: false, + awayMessage: undefined, + }; + } + return s; + }); + + // Update current user if this is the selected server + let updatedCurrentUser = state.currentUser; + if (state.ui.selectedServerId === serverId && state.currentUser) { + updatedCurrentUser = { + ...state.currentUser, + isAway: false, + awayMessage: undefined, + }; + } + + return { servers: updatedServers, currentUser: updatedCurrentUser }; + }); +}); + +// Batch event handlers +ircClient.on("BATCH_START", ({ serverId, batchId, type, parameters }) => { + useStore.setState((state) => { + const serverBatches = state.activeBatches[serverId] || {}; + return { + activeBatches: { + ...state.activeBatches, + [serverId]: { + ...serverBatches, + [batchId]: { + type, + parameters: parameters || [], + events: [], + startTime: new Date(), + }, + }, + }, + }; + }); +}); + +ircClient.on("BATCH_END", ({ serverId, batchId }) => { + useStore.setState((state) => { + const serverBatches = state.activeBatches[serverId]; + if (!serverBatches || !serverBatches[batchId]) { + return state; + } + + const batch = serverBatches[batchId]; + + // Process the batch based on its type + if (batch.type === "netsplit") { + processBatchedNetsplit(serverId, batchId, batch); + } else if (batch.type === "netjoin") { + processBatchedNetjoin(serverId, batchId, batch); + } else if (batch.type === "draft/multiline" || batch.type === "multiline") { + // Multiline batches are handled by the IRC client directly via MULTILINE_MESSAGE events + // Don't process individual events here, the IRC client already combined them + } else if (batch.type === "metadata") { + // Metadata batches are handled by the IRC client directly via individual METADATA events + // Don't process individual events here, metadata updates are already processed + } else if (batch.type === "chathistory") { + // Chathistory batch completed - turn off loading state for the channel + + // Try to determine the channel from batch parameters + // Chathistory batch parameters typically include the channel name + const channelName = + batch.parameters && batch.parameters.length > 0 + ? batch.parameters[0] + : null; + + if (channelName) { + // Trigger event to turn off loading state + ircClient.triggerEvent("CHATHISTORY_LOADING", { + serverId, + channelName, + isLoading: false, + }); + } + } else { + // For unknown batch types, process events individually + batch.events.forEach((event) => { + // Re-trigger the event without batch context based on its type + switch (event.type) { + case "JOIN": + ircClient.triggerEvent("JOIN", event.data); + break; + case "QUIT": + ircClient.triggerEvent("QUIT", event.data); + break; + case "PART": + ircClient.triggerEvent("PART", event.data); + break; + } + }); + } + + // Remove the completed batch + const { [batchId]: removed, ...remainingBatches } = serverBatches; + return { + activeBatches: { + ...state.activeBatches, + [serverId]: remainingBatches, + }, + }; + }); +}); + +// NAMES reply handler - when we get the initial user list for a channel +ircClient.on("NAMES", ({ serverId, channelName, users }) => { + useStore.setState((state) => { + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + const updatedChannels = server.channels.map((channel) => { + if (channel.name.toLowerCase() === channelName.toLowerCase()) { + // Add users from NAMES reply to the channel + const existingUsernames = new Set( + channel.users.map((u) => u.username.toLowerCase()), + ); + + const newUsers = users + .filter( + (user) => !existingUsernames.has(user.username.toLowerCase()), + ) + .map((user) => { + // Check if we already have metadata for this user from localStorage or other channels + let existingMetadata = {}; + + // First check localStorage + const savedMetadata = loadSavedMetadata(); + const serverMetadata = savedMetadata[serverId]; + if (serverMetadata?.[user.username]) { + existingMetadata = { ...serverMetadata[user.username] }; + } + + // Then check if user exists in other channels and has metadata + if (Object.keys(existingMetadata).length === 0) { + for (const otherChannel of server.channels) { + if ( + otherChannel.name.toLowerCase() !== + channelName.toLowerCase() + ) { + const existingUser = otherChannel.users.find( + (u) => + u.username.toLowerCase() === + user.username.toLowerCase(), + ); + if ( + existingUser?.metadata && + Object.keys(existingUser.metadata).length > 0 + ) { + existingMetadata = { ...existingUser.metadata }; + break; + } + } + } + } + + return { + ...user, + id: uuidv4(), + isOnline: true, + metadata: existingMetadata, + }; + }); + + return { + ...channel, + users: [...channel.users, ...newUsers], + }; + } + return channel; + }); + + return { + ...server, + channels: updatedChannels, + }; + } + return server; + }); + + // Request metadata for users who don't have it yet + if (serverSupportsMetadata(serverId)) { + const serverData = state.servers.find((s) => s.id === serverId); + const channelData = serverData?.channels.find( + (c) => c.name.toLowerCase() === channelName.toLowerCase(), + ); + + if (channelData) { + // Request metadata for users who don't have it + channelData.users.forEach((user) => { + const hasMetadata = + user.metadata && Object.keys(user.metadata).length > 0; + if (!hasMetadata) { + useStore.getState().metadataList(serverId, user.username); + } + }); + } + } + + // Check if current user has operator status in this channel and update their modes + const currentUser = state.currentUser; + if (currentUser) { + const currentUserInChannel = users.find( + (user) => + user.username.toLowerCase() === currentUser.username.toLowerCase(), + ); + if (currentUserInChannel?.status) { + // Check if user has operator status (contains '@' or other operator prefixes) + const hasOperatorStatus = + currentUserInChannel.status.includes("@") || + currentUserInChannel.status.includes("~") || + currentUserInChannel.status.includes("&"); + if ( + hasOperatorStatus && + (!currentUser.modes || !currentUser.modes.includes("o")) + ) { + // Update currentUser with operator modes + return { + servers: updatedServers, + currentUser: { + ...currentUser, + modes: currentUser.modes ? `${currentUser.modes}o` : "o", + }, + }; + } + } + } + + return { servers: updatedServers }; + }); +}); + +export default useStore; diff --git a/src/store/index.ts b/src/store/index.ts index 25ddcbdc..7fd789a9 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,8090 +1,140 @@ -import { v4 as uuidv4 } from "uuid"; -import { create } from "zustand"; -import { isUserIgnored } from "../lib/ignoreUtils"; -import ircClient from "../lib/ircClient"; -import { - playNotificationSound, - shouldPlayNotificationSound, -} from "../lib/notificationSounds"; -import { - checkForMention, - extractMentions, - showMentionNotification, -} from "../lib/notifications"; -import { registerAllProtocolHandlers } from "../protocol"; -import type { - Channel, - Message, - PrivateChat, - Server, - ServerConfig, - User, - WhoisData, -} from "../types"; - -const LOCAL_STORAGE_SERVERS_KEY = "savedServers"; -const LOCAL_STORAGE_METADATA_KEY = "serverMetadata"; -const LOCAL_STORAGE_SETTINGS_KEY = "globalSettings"; -const LOCAL_STORAGE_CHANNEL_ORDER_KEY = "channelOrder"; -const LOCAL_STORAGE_PINNED_PMS_KEY = "pinnedPrivateChats"; - -// Type for saved metadata structure: serverId -> target -> key -> metadata -type SavedMetadata = Record< - string, - Record> ->; - -// Type for pinned private chats: serverId -> array of {username, order} -type PinnedPrivateChatsMap = Record< - string, - Array<{ username: string; order: number }> ->; - -// Type for channel order: serverId -> array of channel names in order -type ChannelOrderMap = Record; - -// Types for batch event processing -interface JoinBatchEvent { - type: "JOIN"; - data: { - serverId: string; - username: string; - channelName: string; - account?: string; // From extended-join - realname?: string; // From extended-join - }; -} - -interface QuitBatchEvent { - type: "QUIT"; - data: { - serverId: string; - username: string; - reason: string; - }; -} - -interface PartBatchEvent { - type: "PART"; - data: { - serverId: string; - username: string; - channelName: string; - reason?: string; - }; -} - -type BatchEvent = JoinBatchEvent | QuitBatchEvent | PartBatchEvent; - -interface BatchInfo { - type: string; - parameters?: string[]; - events: BatchEvent[]; - startTime: Date; -} - -interface Attachment { - id: string; - type: "image"; - url: string; - filename: string; -} +import { create, type StoreApi } from "zustand"; +import { devtools, persist } from "zustand/middleware"; +import { immer } from "zustand/middleware/immer"; +import { persistConfig } from "./middleware/persistConfig"; +import { createChannelSlice } from "./slices/channelSlice"; +import { createIRCActionsSlice } from "./slices/ircActionsSlice"; +import { createMessageSlice } from "./slices/messageSlice"; +import { createMetadataSlice } from "./slices/metadataSlice"; +import { createNotificationSlice } from "./slices/notificationSlice"; +import { createPrivateChatSlice } from "./slices/privateChatSlice"; +import { createServerSlice } from "./slices/serverSlice"; +import { createSettingsSlice } from "./slices/settingsSlice"; +import { createUISlice } from "./slices/uiSlice"; +import type { AppState } from "./types"; + +/** + * Main Zustand store combining all slices with middleware + * - Immer: Allows direct state mutation (converted to immutable updates) + * - Persist: Automatic localStorage synchronization + * - Devtools: Redux DevTools integration + */ +export const useStore = create()( + devtools( + persist( + immer((...a) => ({ + ...createSettingsSlice( + ...(a as Parameters), + ), + ...createNotificationSlice( + ...(a as Parameters), + ), + ...createUISlice(...(a as Parameters)), + ...createMessageSlice(...(a as Parameters)), + ...createPrivateChatSlice( + ...(a as Parameters), + ), + ...createChannelSlice(...(a as Parameters)), + ...createMetadataSlice( + ...(a as Parameters), + ), + ...createServerSlice(...(a as Parameters)), + ...createIRCActionsSlice( + ...(a as Parameters), + ), + })), + persistConfig, + ), + { name: "ObsidianIRC Store" }, + ), +); +// Export commonly used selectors export const getChannelMessages = (serverId: string, channelId: string) => { const state = useStore.getState(); - const key = `${serverId}-${channelId}`; - return state.messages[key] || []; + return state.getChannelMessages(serverId, channelId); }; export const findChannelMessageById = ( serverId: string, channelId: string, messageId: string, -): Message | undefined => { - const messages = getChannelMessages(serverId, channelId); - return messages.find((message) => message.msgid === messageId); +) => { + const state = useStore.getState(); + return state.findMessageById(serverId, channelId, messageId); }; -// Load saved servers from localStorage -export function loadSavedServers(): ServerConfig[] { - return JSON.parse(localStorage.getItem(LOCAL_STORAGE_SERVERS_KEY) || "[]"); -} - -// Load saved metadata from localStorage -export function loadSavedMetadata(): SavedMetadata { - return JSON.parse(localStorage.getItem(LOCAL_STORAGE_METADATA_KEY) || "{}"); -} - -// Save metadata to localStorage -function saveMetadataToLocalStorage(metadata: SavedMetadata) { - localStorage.setItem(LOCAL_STORAGE_METADATA_KEY, JSON.stringify(metadata)); -} - -// Load saved global settings from localStorage -function loadSavedGlobalSettings(): Partial { - try { - return JSON.parse(localStorage.getItem(LOCAL_STORAGE_SETTINGS_KEY) || "{}"); - } catch { - return {}; - } -} - -// Save global settings to localStorage -function saveGlobalSettingsToLocalStorage(settings: GlobalSettings) { - localStorage.setItem(LOCAL_STORAGE_SETTINGS_KEY, JSON.stringify(settings)); -} - -// Load channel order from localStorage -function loadChannelOrder(): ChannelOrderMap { - return JSON.parse( - localStorage.getItem(LOCAL_STORAGE_CHANNEL_ORDER_KEY) || "{}", - ); -} - -// Save channel order to localStorage -function saveChannelOrder(channelOrder: ChannelOrderMap) { - localStorage.setItem( - LOCAL_STORAGE_CHANNEL_ORDER_KEY, - JSON.stringify(channelOrder), - ); -} -// Load pinned private chats from localStorage -function loadPinnedPrivateChats(): PinnedPrivateChatsMap { - try { - return JSON.parse( - localStorage.getItem(LOCAL_STORAGE_PINNED_PMS_KEY) || "{}", - ); - } catch { - return {}; - } +// Export store state access functions for backward compatibility +export function loadSavedServers() { + return useStore.getState().servers || []; } -// Save pinned private chats to localStorage -function savePinnedPrivateChats(pinnedChats: PinnedPrivateChatsMap) { - localStorage.setItem( - LOCAL_STORAGE_PINNED_PMS_KEY, - JSON.stringify(pinnedChats), +// Helper to check if server supports metadata +export function serverSupportsMetadata(serverId: string): boolean { + const state = useStore.getState(); + return ( + state.hasServerCapability(serverId, "draft/metadata-2") || + state.hasServerCapability(serverId, "draft/metadata") ); } -// Check if a server supports metadata -function serverSupportsMetadata(serverId: string): boolean { +// Helper to check if server supports multiline +export function serverSupportsMultiline(serverId: string): boolean { const state = useStore.getState(); - const server = state.servers.find((s) => s.id === serverId); - const supports = - server?.capabilities?.some( - (cap) => cap === "draft/metadata-2" || cap.startsWith("draft/metadata"), - ) ?? false; - return supports; + return state.hasServerCapability(serverId, "draft/multiline"); } -// Check if a server supports multiline -function serverSupportsMultiline(serverId: string): boolean { - const state = useStore.getState(); - const server = state.servers.find((s) => s.id === serverId); - const supports = server?.capabilities?.includes("draft/multiline") ?? false; - return supports; +// Note: saveServersToLocalStorage is no longer needed - handled by persist middleware +// Stub exports for backward compatibility +export function saveServersToLocalStorage(_servers?: unknown) { + // No-op: persist middleware handles this automatically } -export { serverSupportsMetadata, serverSupportsMultiline }; - -function saveServersToLocalStorage(servers: ServerConfig[]) { - localStorage.setItem(LOCAL_STORAGE_SERVERS_KEY, JSON.stringify(servers)); +export function loadSavedMetadata(): Record< + string, + Record< + string, + Record + > +> { + // Return empty metadata - persist middleware handles this automatically + return {}; } -// Export the function -export { saveServersToLocalStorage }; - -// Restore metadata for a server from localStorage -function restoreServerMetadata(serverId: string) { - const savedMetadata = loadSavedMetadata(); - const serverMetadata = savedMetadata[serverId]; - if (!serverMetadata) return; - - useStore.setState((state) => { - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - // Restore server metadata - const updatedMetadata = { ...server.metadata }; - if (serverMetadata[server.name]) { - Object.assign(updatedMetadata, serverMetadata[server.name]); - } - - // Restore user metadata in channels - const updatedChannels = server.channels.map((channel) => { - const updatedUsers = channel.users.map((user) => { - const userMetadata = serverMetadata[user.username]; - if (userMetadata) { - return { - ...user, - metadata: { ...user.metadata, ...userMetadata }, - }; - } - return user; - }); +// Initialize IRC event handlers after store creation +// This will be set up in ircAdapter.ts +let ircAdapterInitialized = false; - // Restore channel metadata - const channelMetadata = serverMetadata[channel.name]; - const updatedChannelMetadata = channel.metadata || {}; - if (channelMetadata) { - Object.assign(updatedChannelMetadata, channelMetadata); - } - - return { - ...channel, - users: updatedUsers, - metadata: updatedChannelMetadata, - }; - }); - - return { - ...server, - metadata: updatedMetadata, - channels: updatedChannels, - }; - } - return server; +export function initializeIRCAdapter() { + if (!ircAdapterInitialized) { + // Import and initialize IRC adapter + import("./adapters/ircAdapter").then(({ initializeIRCEventHandlers }) => { + // Pass the store directly - useStore is a UseBoundStore which contains the StoreApi + // Type assertion to avoid TypeScript's type instantiation depth limit with complex store types + initializeIRCEventHandlers(useStore as unknown as StoreApi); + ircAdapterInitialized = true; }); - - // Restore current user metadata - let updatedCurrentUser = state.currentUser; - if (state.currentUser && serverMetadata[state.currentUser.username]) { - updatedCurrentUser = { - ...state.currentUser, - metadata: { - ...state.currentUser.metadata, - ...serverMetadata[state.currentUser.username], - }, - }; - } - - return { servers: updatedServers, currentUser: updatedCurrentUser }; - }); -} - -// Fetch our own metadata from the server and update saved values -async function fetchAndMergeOwnMetadata(serverId: string): Promise { - return new Promise((resolve) => { - const nickname = ircClient.getNick(serverId); - if (!nickname) { - resolve(); - return; - } - - // Mark as fetching - useStore.setState((state) => ({ - metadataFetchInProgress: { - ...state.metadataFetchInProgress, - [serverId]: true, - }, - })); - - // Request all metadata for ourselves (target "*" means us) - const defaultKeys = [ - "url", - "website", - "status", - "location", - "avatar", - "color", - "display-name", - ]; - - // Get our metadata from the server - ircClient.metadataGet(serverId, "*", defaultKeys); - - // Wait a bit for responses to come in, then resolve - // The METADATA_KEYVALUE handler will update saved values - setTimeout(() => { - useStore.setState((state) => ({ - metadataFetchInProgress: { - ...state.metadataFetchInProgress, - [serverId]: false, - }, - })); - resolve(); - }, 1000); - }); -} - -// Fetch channel metadata for the channel list modal -// Uses caching to avoid refetching and rate limiting -function fetchChannelMetadata(serverId: string, channelNames: string[]) { - const state = useStore.getState(); - const now = Date.now(); - const CACHE_TTL = 5 * 60 * 1000; // 5 minutes cache - - // Initialize cache and queue if needed - if (!state.channelMetadataCache[serverId]) { - useStore.setState((state) => ({ - channelMetadataCache: { - ...state.channelMetadataCache, - [serverId]: {}, - }, - })); - } - if (!state.channelMetadataFetchQueue[serverId]) { - useStore.setState((state) => ({ - channelMetadataFetchQueue: { - ...state.channelMetadataFetchQueue, - [serverId]: new Set(), - }, - })); - } - - const cache = state.channelMetadataCache[serverId] || {}; - const queue = state.channelMetadataFetchQueue[serverId] || new Set(); - - // Filter out channels that are already cached or being fetched - const channelsToFetch = channelNames.filter((channelName) => { - const cached = cache[channelName]; - const alreadyQueued = queue.has(channelName); - const isCacheValid = cached && now - cached.fetchedAt < CACHE_TTL; - return !isCacheValid && !alreadyQueued; - }); - - if (channelsToFetch.length === 0) { - return; - } - - // Add to queue - const newQueue = new Set(queue); - for (const ch of channelsToFetch) { - newQueue.add(ch); } - useStore.setState((state) => ({ - channelMetadataFetchQueue: { - ...state.channelMetadataFetchQueue, - [serverId]: newQueue, - }, - })); - - // Fetch metadata for each channel - // Note: We request metadata even if we're not in the channel - // This may not work on all servers - depends on server permissions - channelsToFetch.forEach((channelName) => { - ircClient.metadataGet(serverId, channelName, ["avatar", "display-name"]); - }); -} - -interface UIState { - selectedServerId: string | null; - // Per-server tab selections - remembers what was selected in each server - perServerSelections: Record< - string, - { - selectedChannelId: string | null; - selectedPrivateChatId: string | null; - } - >; - isAddServerModalOpen: boolean | undefined; - isEditServerModalOpen: boolean; - editServerId: string | null; - isSettingsModalOpen: boolean; - isUserProfileModalOpen: boolean; - isDarkMode: boolean; - isMobileMenuOpen: boolean; - isMemberListVisible: boolean; - isChannelListVisible: boolean; - isChannelListModalOpen: boolean; - isChannelRenameModalOpen: boolean; - mobileViewActiveColumn: layoutColumn; - isServerMenuOpen: boolean; - contextMenu: { - isOpen: boolean; - x: number; - y: number; - type: "server" | "channel" | "user" | "message"; - itemId: string | null; - }; - prefillServerDetails: ConnectionDetails | null; - inputAttachments: Attachment[]; - // Link security warning modal state - array to support multiple concurrent warnings - linkSecurityWarnings: Array<{ serverId: string; timestamp: number }>; - // Server notices popup state - isServerNoticesPopupOpen: boolean; - serverNoticesPopupMinimized: boolean; - // Profile view request - set when we want to open a user profile after closing settings - profileViewRequest: { serverId: string; username: string } | null; - // Shimmer effect for newly connected servers - serverShimmer?: Set; // Set of server IDs that should show shimmer -} - -export interface GlobalSettings { - enableNotifications: boolean; - notificationSound: string; - enableNotificationSounds: boolean; - notificationVolume: number; // 0-1, where 0 is muted - enableHighlights: boolean; - sendTypingNotifications: boolean; - // Event visibility settings - showEvents: boolean; - showNickChanges: boolean; - showJoinsParts: boolean; - showQuits: boolean; - showKicks: boolean; - // Custom mentions - customMentions: string[]; - // Ignore list - ignoreList: string[]; - // Hosted chat mode settings - nickname: string; - accountName: string; - accountPassword: string; - // Multiline settings - enableMultilineInput: boolean; - multilineOnShiftEnter: boolean; - autoFallbackToSingleLine: boolean; - // Media settings - showSafeMedia: boolean; - showExternalContent: boolean; - // Markdown settings - enableMarkdownRendering: boolean; } -export interface AppState { - servers: Server[]; - currentUser: User | null; - isConnecting: boolean; - selectedServerId: string | null; - connectionError: string | null; - messages: Record; - typingUsers: Record; - typingTimers: Record>; - globalNotifications: { - id: string; - type: "fail" | "warn" | "note"; - command: string; - code: string; - message: string; - target?: string; - serverId: string; - timestamp: Date; - }[]; - channelList: Record< - string, - { channel: string; userCount: number; topic: string }[] - >; // serverId -> channels - channelListBuffer: Record< - string, - { channel: string; userCount: number; topic: string }[] - >; // serverId -> channels (temporary buffer during listing) - channelListFilters: Record< - string, - { - minUsers?: number; - maxUsers?: number; - minCreationTime?: number; // minutes ago - maxCreationTime?: number; // minutes ago - minTopicTime?: number; // minutes ago - maxTopicTime?: number; // minutes ago - mask?: string; - notMask?: string; - } - >; // serverId -> filter settings - listingInProgress: Record; // serverId -> is listing - // Channel metadata cache for /LIST - channelMetadataCache: Record< - string, - Record< - string, - { - avatar?: string; - displayName?: string; - fetchedAt: number; // timestamp - } - > - >; // serverId -> channelName -> metadata - channelMetadataFetchQueue: Record>; // serverId -> Set of channel names being fetched - // Metadata state - metadataSubscriptions: Record; // serverId -> keys - metadataBatches: Record< - string, - { - type: string; - messages: { - target: string; - key: string; - visibility: string; - value: string; - }[]; - } - >; // batchId -> batch info - activeBatches: Record>; // serverId -> batchId -> batch info - metadataFetchInProgress: Record; // serverId -> is fetching own metadata - userMetadataRequested: Record>; // serverId -> Set of usernames we've requested metadata for - metadataChangeCounter: number; // Counter incremented on metadata changes for reactivity - // WHOIS data cache - whoisData: Record>; // serverId -> nickname -> whois data - // Account registration state - pendingRegistration: { - serverId: string; - account: string; - email: string; - password: string; - } | null; - // Channel order persistence - channelOrder: ChannelOrderMap; // serverId -> ordered array of channel names - // Message deduplication tracking - processedMessageIds: Set; // Set of msgid values that have already been processed - // Auto-connect prevention - hasConnectedToSavedServers: boolean; - // UI state - ui: UIState; - globalSettings: GlobalSettings; - // Actions - connect: ( - name: string, - host: string, - port: number, - nickname: string, - saslEnabled: boolean, - password?: string, - saslAccountName?: string, - saslPassword?: string, - registerAccount?: boolean, - registerEmail?: string, - registerPassword?: string, - ) => Promise; - disconnect: (serverId: string) => void; - joinChannel: (serverId: string, channelName: string) => void; - leaveChannel: (serverId: string, channelName: string) => void; - sendMessage: (serverId: string, channelId: string, content: string) => void; - redactMessage: ( - serverId: string, - target: string, - msgid: string, - reason?: string, - ) => void; - registerAccount: ( - serverId: string, - account: string, - email: string, - password: string, - ) => void; - verifyAccount: (serverId: string, account: string, code: string) => void; - warnUser: ( - serverId: string, - channelName: string, - username: string, - reason: string, - ) => void; - kickUser: ( - serverId: string, - channelName: string, - username: string, - reason: string, - ) => void; - banUser: ( - serverId: string, - channelName: string, - username: string, - reason: string, - ) => void; - banUserByNick: ( - serverId: string, - channelName: string, - username: string, - reason: string, - ) => void; - banUserByHostmask: ( - serverId: string, - channelName: string, - username: string, - reason: string, - ) => void; - listChannels: ( - serverId: string, - filters?: { - minUsers?: number; - maxUsers?: number; - minCreationTime?: number; // minutes ago - maxCreationTime?: number; // minutes ago - minTopicTime?: number; // minutes ago - maxTopicTime?: number; // minutes ago - mask?: string; - notMask?: string; - }, - ) => void; - updateChannelListFilters: ( - serverId: string, - filters: { - minUsers?: number; - maxUsers?: number; - minCreationTime?: number; // minutes ago - maxCreationTime?: number; // minutes ago - minTopicTime?: number; // minutes ago - maxTopicTime?: number; // minutes ago - mask?: string; - notMask?: string; - }, - ) => void; - renameChannel: ( - serverId: string, - oldName: string, - newName: string, - reason?: string, - ) => void; - setName: (serverId: string, realname: string) => void; - changeNick: (serverId: string, newNick: string) => void; - addMessage: (message: Message) => void; - addGlobalNotification: (notification: { - type: "fail" | "warn" | "note"; - command: string; - code: string; - message: string; - target?: string; - serverId: string; - }) => void; - removeGlobalNotification: (notificationId: string) => void; - clearGlobalNotifications: () => void; - selectServer: (serverId: string | null) => void; - selectChannel: (channelId: string | null) => void; - selectPrivateChat: (privateChatId: string | null) => void; - openPrivateChat: (serverId: string, username: string) => void; - deletePrivateChat: (serverId: string, privateChatId: string) => void; - pinPrivateChat: (serverId: string, privateChatId: string) => void; - unpinPrivateChat: (serverId: string, privateChatId: string) => void; - reorderPrivateChats: (serverId: string, privateChatIds: string[]) => void; - markChannelAsRead: (serverId: string, channelId: string) => void; - reorderChannels: (serverId: string, channelIds: string[]) => void; - connectToSavedServers: () => void; // New action to load servers from localStorage - reconnectServer: (serverId: string) => Promise; // Reconnect to an existing server - deleteServer: (serverId: string) => void; // New action to delete a server - updateServer: (serverId: string, config: Partial) => void; // Update server configuration - capAck: (serverId: string, key: string, capabilities: string) => void; // Handle CAP ACK - // UI actions - toggleAddServerModal: ( - isOpen?: boolean, - prefillDetails?: ConnectionDetails | null, - ) => void; - toggleEditServerModal: (isOpen?: boolean, serverId?: string | null) => void; - toggleSettingsModal: (isOpen?: boolean) => void; - toggleUserProfileModal: (isOpen?: boolean) => void; - setProfileViewRequest: (serverId: string, username: string) => void; - clearProfileViewRequest: () => void; - toggleDarkMode: () => void; - toggleMobileMenu: (isOpen?: boolean) => void; - toggleMemberList: (isVisible?: boolean) => void; - toggleChannelList: (isOpen?: boolean) => void; - toggleChannelListModal: (isOpen?: boolean) => void; - toggleChannelRenameModal: (isOpen?: boolean) => void; - toggleServerMenu: (isOpen?: boolean) => void; - showContextMenu: ( - x: number, - y: number, - type: "server" | "channel" | "user" | "message", - itemId: string, - ) => void; - hideContextMenu: () => void; - setMobileViewActiveColumn: (column: layoutColumn) => void; - // Server notices popup actions - toggleServerNoticesPopup: (isOpen?: boolean) => void; - minimizeServerNoticesPopup: (isMinimized?: boolean) => void; - // Shimmer actions - triggerServerShimmer: (serverId: string) => void; - clearServerShimmer: (serverId: string) => void; - // Settings actions - updateGlobalSettings: (settings: Partial) => void; - // Ignore list actions - addToIgnoreList: (pattern: string) => void; - removeFromIgnoreList: (pattern: string) => void; - // Attachment actions - addInputAttachment: (attachment: Attachment) => void; - removeInputAttachment: (attachmentId: string) => void; - clearInputAttachments: () => void; - // Metadata actions - metadataGet: (serverId: string, target: string, keys: string[]) => void; - metadataList: (serverId: string, target: string) => void; - metadataSet: ( - serverId: string, - target: string, - key: string, - value?: string, - visibility?: string, - ) => void; - metadataClear: (serverId: string, target: string) => void; - metadataSub: (serverId: string, keys: string[]) => void; - metadataUnsub: (serverId: string, keys: string[]) => void; - metadataSubs: (serverId: string) => void; - metadataSync: (serverId: string, target: string) => void; - sendRaw: (serverId: string, command: string) => void; +// Auto-initialize IRC adapter +// This ensures event handlers are set up when the store is created +if (typeof window !== "undefined") { + // Only initialize in browser environment + initializeIRCAdapter(); } -// Helper functions for per-server tab selections -const getServerSelection = (state: AppState, serverId: string) => { - return ( - state.ui.perServerSelections[serverId] || { - selectedChannelId: null, - selectedPrivateChatId: null, - } - ); -}; - -const setServerSelection = ( - state: AppState, - serverId: string, - selection: { - selectedChannelId: string | null; - selectedPrivateChatId: string | null; - }, -) => { - return { - ...state.ui.perServerSelections, - [serverId]: selection, - }; -}; - -const getCurrentSelection = (state: AppState) => { - if (!state.ui.selectedServerId) { - return { - selectedChannelId: null, - selectedPrivateChatId: null, - }; - } - return getServerSelection(state, state.ui.selectedServerId); -}; - -// Create store with Zustand -const useStore = create((set, get) => ({ - servers: [], - currentUser: null, - isConnecting: false, - connectionError: null, - messages: {}, - typingUsers: {}, - typingTimers: {}, - globalNotifications: [], - channelList: {}, - channelListBuffer: {}, - channelListFilters: {}, - listingInProgress: {}, - channelMetadataCache: {}, - channelMetadataFetchQueue: {}, - metadataSubscriptions: {}, - metadataBatches: {}, - activeBatches: {}, - metadataFetchInProgress: {}, - userMetadataRequested: {}, - metadataChangeCounter: 0, - whoisData: {}, - pendingRegistration: null, - channelOrder: loadChannelOrder(), - processedMessageIds: new Set(), - hasConnectedToSavedServers: false, - selectedServerId: null, - - // UI state - ui: { - selectedServerId: null, - perServerSelections: {}, - isAddServerModalOpen: false, - isEditServerModalOpen: false, - editServerId: null, - isSettingsModalOpen: false, - isUserProfileModalOpen: false, - isDarkMode: true, // Discord-like default is dark mode - isMobileMenuOpen: false, - isMemberListVisible: true, - isChannelListVisible: true, - isChannelListModalOpen: false, - isChannelRenameModalOpen: false, - mobileViewActiveColumn: "serverList", // Default to server list in mobile mode on open - isServerMenuOpen: false, - contextMenu: { - isOpen: false, - x: 0, - y: 0, - type: "server", - itemId: null, - }, - prefillServerDetails: null, - inputAttachments: [], - // Link security warning modal state - linkSecurityWarnings: [], - // Server notices popup state - isServerNoticesPopupOpen: false, - serverNoticesPopupMinimized: false, - // Profile view request - profileViewRequest: null, - }, - globalSettings: { - enableNotifications: false, - notificationSound: "/sounds/notif1.mp3", - enableNotificationSounds: true, - notificationVolume: 0.4, // 40% volume by default - enableHighlights: true, - sendTypingNotifications: true, - // Event visibility settings (enabled by default) - showEvents: true, - showNickChanges: true, - showJoinsParts: true, - showQuits: true, - showKicks: true, - // Custom mentions - customMentions: [], - // Ignore list - ignoreList: ["HistServ!*@*"], - // Hosted chat mode settings - nickname: "", - accountName: "", - accountPassword: "", - // Multiline settings - enableMultilineInput: true, - multilineOnShiftEnter: true, - autoFallbackToSingleLine: true, - // Media settings - showSafeMedia: true, - showExternalContent: false, - // Markdown settings - enableMarkdownRendering: false, - ...loadSavedGlobalSettings(), // Load saved settings from localStorage - }, - - // IRC client actions - connect: async ( - name, - host, - port, - nickname, - _saslEnabled, - password, - saslAccountName, - saslPassword, - registerAccount, - registerEmail, - registerPassword, - ) => { - // Check if already connected to this server - const state = get(); - const existingServer = state.servers.find( - (s) => s.host === host && s.port === port && s.isConnected, - ); - if (existingServer) { - // Already connected, just return the existing server - return existingServer; - } - - set({ isConnecting: true, connectionError: null }); - - try { - // Look up saved server to get its ID - const existingSavedServers: ServerConfig[] = loadSavedServers(); - const existingSavedServer = existingSavedServers.find( - (s) => s.host === host && s.port === port, - ); - - const server = await ircClient.connect( - name, - host, - port, - nickname, - password, - saslAccountName, - saslPassword, - existingSavedServer?.id, // Pass the saved server ID if it exists - ); - - // Save server to localStorage - const savedServers: ServerConfig[] = loadSavedServers(); - const savedServer = savedServers.find( - (s) => s.host === host && s.port === port, - ); - const channelsToJoin = savedServer?.channels || []; - - const updatedServers = savedServers.filter( - (s) => s.host !== host || s.port !== port, - ); - updatedServers.push({ - id: server.id, // Include the server ID here - name: server.name, // Save the server name - host, - port, - nickname, - saslEnabled: !!saslPassword, - password, - channels: channelsToJoin, - saslAccountName, - saslPassword, - // Preserve existing oper credentials and warning preferences - operUsername: savedServer?.operUsername, - operPassword: savedServer?.operPassword, - operOnConnect: savedServer?.operOnConnect, - skipLocalhostWarning: savedServer?.skipLocalhostWarning, - skipLinkSecurityWarning: savedServer?.skipLinkSecurityWarning, - }); - saveServersToLocalStorage(updatedServers); - - set((state) => { - const existingServerIndex = state.servers.findIndex( - (s) => s.host === host && s.port === port, - ); - if (existingServerIndex !== -1) { - // Update existing server properties - const updatedServers = [...state.servers]; - const existingServer = updatedServers[existingServerIndex]; - updatedServers[existingServerIndex] = { - ...existingServer, - ...server, - id: existingServer.id, // Keep the original ID - }; - return { - servers: updatedServers, - isConnecting: false, - }; - } - return { - servers: [...state.servers, server], - isConnecting: false, - }; - }); - - // Check for localhost connection warning (unencrypted ws://) - const isLocalhost = host === "localhost" || host === "127.0.0.1"; - if (isLocalhost) { - const savedServers = loadSavedServers(); - const serverConfig = savedServers.find( - (s) => s.host === host && s.port === port, - ); - - // Only show warning if not already skipped - if (!serverConfig?.skipLocalhostWarning) { - set((state) => ({ - ui: { - ...state.ui, - linkSecurityWarnings: [ - ...state.ui.linkSecurityWarnings, - { serverId: server.id, timestamp: Date.now() }, - ], - }, - })); - } - } - - // Join saved channels - now handled in the ready event handler - // for (const channelName of channelsToJoin) { - // get().joinChannel(server.id, channelName); - // } - - // Set up pending account registration if requested - if (registerAccount && registerEmail && registerPassword) { - set({ - pendingRegistration: { - serverId: server.id, - account: nickname, // Use nickname as account name for now - email: registerEmail, - password: registerPassword, - }, - }); - } - - return server; - } catch (error) { - // Even if connection fails, add the server to the store as disconnected - // so it appears in the UI and can be reconnected later - const disconnectedServer: Server = { - id: uuidv4(), - name: name || host, - host, - port, - channels: [], - privateChats: [], - isConnected: false, - connectionState: "disconnected", - users: [], - }; - - set((state) => { - const existingServerIndex = state.servers.findIndex( - (s) => s.host === host && s.port === port, - ); - if (existingServerIndex !== -1) { - // Update existing server to disconnected - const updatedServers = [...state.servers]; - updatedServers[existingServerIndex] = { - ...updatedServers[existingServerIndex], - isConnected: false, - connectionState: "disconnected", - }; - return { - servers: updatedServers, - isConnecting: false, - connectionError: - error instanceof Error ? error.message : "Unknown error", - }; - } - return { - servers: [...state.servers, disconnectedServer], - isConnecting: false, - connectionError: - error instanceof Error ? error.message : "Unknown error", - }; - }); - - throw error; - } - }, - - disconnect: (serverId) => { - ircClient.disconnect(serverId); - - // Update the state to reflect disconnection - set((state) => { - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - return { - ...server, - isConnected: false, - connectionState: "disconnected" as const, - }; - } - return server; - }); - - // Update selected server/channel if we were on the disconnected server - let newUi = { ...state.ui }; - if (state.ui.selectedServerId === serverId) { - // Find another connected server, or set to null - const nextServer = updatedServers.find( - (s) => s.isConnected && s.id !== serverId, - ); - if (nextServer) { - // Restore the previously selected tab for the new server - const serverSelection = getServerSelection(state, nextServer.id); - newUi = { - ...newUi, - selectedServerId: nextServer.id, - perServerSelections: setServerSelection( - state, - nextServer.id, - serverSelection, - ), - }; - } else { - newUi = { - ...newUi, - selectedServerId: null, - }; - } - } - - return { - servers: updatedServers, - ui: newUi, - }; - }); - }, - - joinChannel: (serverId, channelName) => { - const channel = ircClient.joinChannel(serverId, channelName); - if (channel) { - set((state) => { - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - // Check if channel already exists in store - const existingChannel = server.channels.find( - (c) => c.name.toLowerCase() === channelName.toLowerCase(), - ); - if (existingChannel) { - // Channel already exists, don't add duplicate - return server; - } - return { - ...server, - channels: [...server.channels, channel], - }; - } - return server; - }); - - // Update localStorage with the new channel - const savedServers = loadSavedServers(); - const currentServer = state.servers.find((s) => s.id === serverId); - const savedServer = savedServers.find( - (s) => - s.host === currentServer?.host && s.port === currentServer?.port, - ); - if (savedServer && !savedServer.channels.includes(channel.name)) { - savedServer.channels.push(channel.name); - saveServersToLocalStorage(savedServers); - } - - // Update channelOrder state to include the new channel - const currentOrder = state.channelOrder[serverId] || []; - if (!currentOrder.includes(channel.name)) { - const newChannelOrder = { - ...state.channelOrder, - [serverId]: [...currentOrder, channel.name], - }; - saveChannelOrder(newChannelOrder); - - // Update the selected channel if the server matches the current selection - const isCurrentServer = state.ui.selectedServerId === serverId; - - return { - servers: updatedServers, - channelOrder: newChannelOrder, - ui: { - ...state.ui, - perServerSelections: setServerSelection(state, serverId, { - selectedChannelId: getCurrentSelection(state).selectedChannelId, - selectedPrivateChatId: - getCurrentSelection(state).selectedPrivateChatId, - }), - }, - }; - } - - // Update the selected channel if the server matches the current selection - const isCurrentServer = state.ui.selectedServerId === serverId; - - return { - servers: updatedServers, - ui: { - ...state.ui, - perServerSelections: setServerSelection(state, serverId, { - selectedChannelId: isCurrentServer - ? channel.id - : getCurrentSelection(state).selectedChannelId, - selectedPrivateChatId: - getCurrentSelection(state).selectedPrivateChatId, - }), - }, - }; - }); - } - }, - - leaveChannel: (serverId, channelName) => { - ircClient.leaveChannel(serverId, channelName); // Send PART command to the IRC server - - set((state) => { - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - return { - ...server, - channels: server.channels.filter( - (channel) => channel.name !== channelName, - ), - }; - } - return server; - }); - - // Update localStorage to remove the channel - const savedServers = loadSavedServers(); - const currentServer = updatedServers.find((s) => s.id === serverId); - const savedServer = savedServers.find( - (s) => s.host === currentServer?.host && s.port === currentServer?.port, - ); - if (savedServer) { - savedServer.channels = currentServer?.channels.map((c) => c.name) || []; - saveServersToLocalStorage(savedServers); - } - - // Update channelOrder to remove the channel - const currentOrder = state.channelOrder[serverId] || []; - const newChannelOrder = { - ...state.channelOrder, - [serverId]: currentOrder.filter((name) => name !== channelName), - }; - saveChannelOrder(newChannelOrder); - - return { - servers: updatedServers, - channelOrder: newChannelOrder, - }; - }); - }, - - sendMessage: (serverId, channelId, content) => { - const message = ircClient.sendMessage(serverId, channelId, content); - }, - - redactMessage: ( - serverId: string, - target: string, - msgid: string, - reason?: string, - ) => { - ircClient.sendRedact(serverId, target, msgid, reason); - }, - - registerAccount: ( - serverId: string, - account: string, - email: string, - password: string, - ) => { - ircClient.registerAccount(serverId, account, email, password); - }, - - verifyAccount: (serverId: string, account: string, code: string) => { - ircClient.verifyAccount(serverId, account, code); - }, - - warnUser: (serverId, channelName, username, reason) => { - // Send a warning message to the user - ircClient.sendRaw(serverId, `PRIVMSG ${username} :Warning: ${reason}`); - }, - - kickUser: (serverId, channelName, username, reason) => { - ircClient.sendRaw(serverId, `KICK ${channelName} ${username} :${reason}`); - }, - - banUser: (serverId, channelName, username, reason) => { - // First ban, then kick - ircClient.sendRaw(serverId, `MODE ${channelName} +b ${username}!*@*`); - ircClient.sendRaw(serverId, `KICK ${channelName} ${username} :${reason}`); - }, - - banUserByNick: (serverId, channelName, username, reason) => { - // Ban by nickname only - ircClient.sendRaw(serverId, `MODE ${channelName} +b ${username}`); - ircClient.sendRaw(serverId, `KICK ${channelName} ${username} :${reason}`); - }, - - banUserByHostmask: (serverId, channelName, username, reason) => { - // Ban by hostmask - look up the user's hostname from the channel or server user list - const state = get(); - const server = state.servers.find((s) => s.id === serverId); - if (!server) return; - - const channel = server.channels.find((c) => c.name === channelName); - // Try to find the user in the channel's user list first, then fall back to server user list - const user = - channel?.users.find((u) => u.username === username) || - server.users.find((u) => u.username === username); - - const hostname = user?.hostname || "*"; - ircClient.sendRaw(serverId, `MODE ${channelName} +b *!*@${hostname}`); - ircClient.sendRaw(serverId, `KICK ${channelName} ${username} :${reason}`); - }, - - listChannels: (serverId, filters?) => { - const state = get(); - if (state.listingInProgress[serverId]) { - // Already listing, ignore - return; - } - // Find the server to check for ELIST support - const server = state.servers.find((s) => s.id === serverId); - const elist = server?.elist; - - // Use provided filters or get stored filters - const filterSettings = filters || state.channelListFilters[serverId] || {}; - - // Clear the channel list and buffer before starting a new list - set((state) => ({ - channelList: { - ...state.channelList, - [serverId]: [], - }, - channelListBuffer: { - ...state.channelListBuffer, - [serverId]: [], - }, - listingInProgress: { - ...state.listingInProgress, - [serverId]: true, - }, - })); - ircClient.listChannels(serverId, elist, filterSettings); - }, - - updateChannelListFilters: (serverId, filters) => { - set((state) => ({ - channelListFilters: { - ...state.channelListFilters, - [serverId]: filters, - }, - })); - }, - - renameChannel: (serverId, oldName, newName, reason) => { - ircClient.renameChannel(serverId, oldName, newName, reason); - }, - - setName: (serverId, realname) => { - ircClient.setName(serverId, realname); - }, - - changeNick: (serverId, newNick) => { - ircClient.changeNick(serverId, newNick); - }, - - addMessage: (message) => { - set((state) => { - const channelKey = `${message.serverId}-${message.channelId}`; - const currentMessages = state.messages[channelKey] || []; - - // Check for duplicate messages (same id, or same content/timestamp/user) - const isDuplicate = currentMessages.some((existingMessage) => { - return ( - existingMessage.id === message.id || - (existingMessage.content === message.content && - existingMessage.timestamp === message.timestamp && - existingMessage.userId === message.userId) - ); - }); - - if (isDuplicate) { - return state; // Don't add duplicate message - } - - // Add message and sort chronologically by timestamp - const updatedMessages = [...currentMessages, message].sort((a, b) => { - const timeA = - a.timestamp instanceof Date - ? a.timestamp.getTime() - : new Date(a.timestamp).getTime(); - const timeB = - b.timestamp instanceof Date - ? b.timestamp.getTime() - : new Date(b.timestamp).getTime(); - return timeA - timeB; - }); - - return { - messages: { - ...state.messages, - [channelKey]: updatedMessages, - }, - }; - }); - }, - - addGlobalNotification: (notification) => { - set((state) => ({ - globalNotifications: [ - ...state.globalNotifications, - { - id: uuidv4(), - ...notification, - timestamp: new Date(), - }, - ], - })); - - // Play error sound for FAIL notifications - if (notification.type === "fail") { - try { - const audio = new Audio("/sounds/error.mp3"); - audio.volume = 0.3; // Set reasonable volume for notifications - audio.play().catch((error) => { - console.error("Failed to play error sound:", error); - }); - } catch (error) { - console.error("Failed to play error sound:", error); - } - } - }, - - removeGlobalNotification: (notificationId) => { - set((state) => ({ - globalNotifications: state.globalNotifications.filter( - (n) => n.id !== notificationId, - ), - })); - }, - - clearGlobalNotifications: () => { - set(() => ({ - globalNotifications: [], - })); - }, - - selectServer: (serverId) => { - set((state) => { - // If selecting null (no server), just update the selectedServerId - if (serverId === null) { - return { - ui: { - ...state.ui, - selectedServerId: null, - isMobileMenuOpen: false, - }, - }; - } - - // Find the server - const server = state.servers.find((s) => s.id === serverId); - - // Get the previously selected tab for this server, or default to first channel - const serverSelection = getServerSelection(state, serverId); - let selectedChannelId = serverSelection.selectedChannelId; - let selectedPrivateChatId = serverSelection.selectedPrivateChatId; - - // If no previous selection or the selected items no longer exist, default to first channel - if (server) { - const channelExists = - selectedChannelId && - server.channels.some((c) => c.id === selectedChannelId); - const privateChatExists = - selectedPrivateChatId && - server.privateChats?.some((pc) => pc.id === selectedPrivateChatId); - - if (!channelExists && !privateChatExists) { - selectedChannelId = server.channels[0]?.id || null; - selectedPrivateChatId = null; - } - } - - return { - ui: { - ...state.ui, - selectedServerId: serverId, - perServerSelections: { - ...state.ui.perServerSelections, - [serverId]: { - selectedChannelId, - selectedPrivateChatId, - }, - }, - isMobileMenuOpen: false, - }, - }; - }); - }, - - selectChannel: (channelId) => { - set((state) => { - // Special case for server notices - if (channelId === "server-notices") { - return { - ui: { - ...state.ui, - perServerSelections: setServerSelection( - state, - state.ui.selectedServerId || "", - { - selectedChannelId: channelId, - selectedPrivateChatId: null, - }, - ), - isMobileMenuOpen: false, - mobileViewActiveColumn: "chatView", - }, - }; - } - - // Find which server this channel belongs to - let serverId = state.ui.selectedServerId; - - // If we don't have a server selected or the channel doesn't belong to the selected server - if (!serverId) { - for (const server of state.servers) { - if (server.channels.some((c) => c.id === channelId)) { - serverId = server.id; - break; - } - } - } - - // Mark channel as read - if (serverId && channelId) { - ircClient.markChannelAsRead(serverId, channelId); - - // Update unread state in store - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - const updatedChannels = server.channels.map((channel) => { - if (channel.id === channelId) { - return { - ...channel, - unreadCount: 0, - isMentioned: false, - }; - } - return channel; - }); - - return { - ...server, - channels: updatedChannels, - }; - } - return server; - }); - - return { - servers: updatedServers, - ui: { - ...state.ui, - selectedServerId: serverId, - perServerSelections: setServerSelection(state, serverId, { - selectedChannelId: channelId, - selectedPrivateChatId: null, - }), - isMobileMenuOpen: false, - mobileViewActiveColumn: "chatView", - }, - }; - } - - return { - ui: { - ...state.ui, - perServerSelections: setServerSelection( - state, - state.ui.selectedServerId || "", - { - selectedChannelId: channelId, - selectedPrivateChatId: null, - }, - ), - isMobileMenuOpen: false, - mobileViewActiveColumn: "chatView", - }, - }; - }); - }, - - markChannelAsRead: (serverId, channelId) => { - ircClient.markChannelAsRead(serverId, channelId); - - set((state) => { - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - const updatedChannels = server.channels.map((channel) => { - if (channel.id === channelId) { - return { - ...channel, - unreadCount: 0, - isMentioned: false, - }; - } - return channel; - }); - - return { - ...server, - channels: updatedChannels, - }; - } - return server; - }); - - return { - servers: updatedServers, - }; - }); - }, - - reorderChannels: (serverId, channelIds) => { - set((state) => { - // Also update the savedServer.channels array to match the new order - const server = state.servers.find((s) => s.id === serverId); - if (server) { - const savedServers = loadSavedServers(); - const savedServer = savedServers.find( - (s) => s.host === server.host && s.port === server.port, - ); - - if (savedServer) { - // Convert channel IDs to channel names in the correct order - const channelNames = channelIds - .map((id) => { - const channel = server.channels.find((c) => c.id === id); - return channel?.name; - }) - .filter((name): name is string => name !== undefined); - - savedServer.channels = channelNames; - saveServersToLocalStorage(savedServers); - - // Store channel names in channelOrder state (not IDs) - const newChannelOrder = { - ...state.channelOrder, - [serverId]: channelNames, - }; - - saveChannelOrder(newChannelOrder); - - return { - channelOrder: newChannelOrder, - }; - } - } - - // Fallback if server not found - return {}; - }); - }, - - selectPrivateChat: (privateChatId) => { - set((state) => { - // Find which server this private chat belongs to - let serverId = state.ui.selectedServerId; - - if (!serverId) { - for (const server of state.servers) { - if (server.privateChats?.some((pc) => pc.id === privateChatId)) { - serverId = server.id; - break; - } - } - } - - // If already selected, do nothing - if ( - serverId && - state.ui.perServerSelections[serverId]?.selectedPrivateChatId === - privateChatId - ) { - return state; - } - - // Mark private chat as read - if (serverId && privateChatId) { - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - const updatedPrivateChats = - server.privateChats?.map((privateChat) => { - if (privateChat.id === privateChatId) { - return { - ...privateChat, - unreadCount: 0, - isMentioned: false, - }; - } - return privateChat; - }) || []; - - return { - ...server, - privateChats: updatedPrivateChats, - }; - } - return server; - }); - - return { - servers: updatedServers, - ui: { - ...state.ui, - selectedServerId: serverId, - perServerSelections: setServerSelection(state, serverId, { - selectedChannelId: null, - selectedPrivateChatId: privateChatId, - }), - isMobileMenuOpen: false, - mobileViewActiveColumn: "chatView", - }, - }; - } - - return { - ui: { - ...state.ui, - perServerSelections: setServerSelection( - state, - state.ui.selectedServerId || "", - { - selectedChannelId: null, - selectedPrivateChatId: privateChatId, - }, - ), - isMobileMenuOpen: false, - mobileViewActiveColumn: "chatView", - }, - }; - }); - }, - - openPrivateChat: (serverId, username) => { - set((state) => { - const server = state.servers.find((s) => s.id === serverId); - if (!server) return {}; - - // Get the current user for this specific server - const currentUser = ircClient.getCurrentUser(serverId); - - // Don't allow opening private chats with ourselves - if (currentUser?.username === username) { - return {}; - } - - // Check if private chat already exists - const existingChat = server.privateChats?.find( - (pc) => pc.username === username, - ); - if (existingChat) { - // MONITOR the user if not already monitored - ircClient.monitorAdd(serverId, [username]); - - // Request chathistory for this PM (if server supports it) - if (server.capabilities?.includes("draft/chathistory")) { - setTimeout(() => { - ircClient.sendRaw(serverId, `CHATHISTORY LATEST ${username} * 50`); - }, 50); - } - - // Check if we already have user info from channels - let hasUserInfo = false; - for (const channel of server.channels) { - const user = channel.users.find( - (u) => u.username.toLowerCase() === username.toLowerCase(), - ); - if (user?.realname && user.account !== undefined) { - // We have complete user info, copy it to the PM - hasUserInfo = true; - useStore.setState((state) => ({ - servers: state.servers.map((s) => { - if (s.id === serverId) { - return { - ...s, - privateChats: s.privateChats?.map((pm) => { - if (pm.username === username) { - return { - ...pm, - realname: user.realname, - account: user.account, - isBot: user.isBot, - }; - } - return pm; - }), - }; - } - return s; - }), - })); - break; - } - } - - // Only request WHO if we don't already have complete user info - if (!hasUserInfo) { - // Request WHO to get current status using WHOX to also get account - // Fields: u=username, h=hostname, n=nickname, f=flags, a=account, r=realname - setTimeout(() => { - ircClient.sendRaw(serverId, `WHO ${username} %cuhnfrao`); - }, 100); - } - - // Note: We don't request METADATA GET for individual users as some servers reject this. - // Instead, we rely on metadata from shared channels (if user is in a channel with us) - // or from localStorage if we previously got their metadata. - - // Select existing private chat - return { - ui: { - ...state.ui, - perServerSelections: setServerSelection(state, serverId, { - selectedChannelId: getCurrentSelection(state).selectedChannelId, - selectedPrivateChatId: - getCurrentSelection(state).selectedPrivateChatId, - }), - }, - }; - } - - // Create new private chat - const newPrivateChat: PrivateChat = { - id: uuidv4(), - username, - serverId, - unreadCount: 0, - isMentioned: false, - lastActivity: new Date(), - isOnline: false, // Will be updated by MONITOR response - isAway: false, - }; - - // Check if we already have user info from channels - let hasUserInfo = false; - for (const channel of server.channels) { - const user = channel.users.find( - (u) => u.username.toLowerCase() === username.toLowerCase(), - ); - if (user?.realname && user.account !== undefined) { - // We have complete user info, copy it to the new PM - hasUserInfo = true; - newPrivateChat.realname = user.realname; - newPrivateChat.account = user.account; - newPrivateChat.isBot = user.isBot; - break; - } - } - - const updatedServers = state.servers.map((s) => { - if (s.id === serverId) { - return { - ...s, - privateChats: [...(s.privateChats || []), newPrivateChat], - }; - } - return s; - }); - - // Add MONITOR for this user (server-specific) - ircClient.monitorAdd(serverId, [username]); - - // Request chathistory for this new PM (if server supports it) - if (server.capabilities?.includes("draft/chathistory")) { - setTimeout(() => { - ircClient.sendRaw(serverId, `CHATHISTORY LATEST ${username} * 50`); - }, 50); - } - - // Only request WHO if we don't already have complete user info - if (!hasUserInfo) { - // Request WHO to get their current status (H=here/green, G=gone/yellow) using WHOX to also get account - // Fields: u=username, h=hostname, n=nickname, f=flags, a=account, r=realname - setTimeout(() => { - ircClient.sendRaw(serverId, `WHO ${username} %cuhnfrao`); - }, 100); - } - - // Note: We don't request METADATA GET for individual users as some servers reject this. - // Instead, we rely on metadata from shared channels (if user is in a channel with us) - // or from localStorage if we previously got their metadata. - - return { - servers: updatedServers, - ui: { - ...state.ui, - perServerSelections: setServerSelection(state, serverId, { - selectedChannelId: getCurrentSelection(state).selectedChannelId, - selectedPrivateChatId: - getCurrentSelection(state).selectedPrivateChatId, - }), - }, - }; - }); - }, - - deletePrivateChat: (serverId, privateChatId) => { - set((state) => { - const server = state.servers.find((s) => s.id === serverId); - if (!server) return {}; - - const privateChat = server.privateChats?.find( - (pc) => pc.id === privateChatId, - ); - - const updatedServers = state.servers.map((s) => { - if (s.id === serverId) { - return { - ...s, - privateChats: - s.privateChats?.filter((pc) => pc.id !== privateChatId) || [], - }; - } - return s; - }); - - // If unpinned, remove MONITOR (but don't UNSUB from metadata - that's global) - if (privateChat && !privateChat.isPinned) { - ircClient.monitorRemove(serverId, [privateChat.username]); - } - - // If the deleted private chat was selected, clear the selection - const newState: Partial = { - servers: updatedServers, - }; - - if (getCurrentSelection(state).selectedPrivateChatId === privateChatId) { - newState.ui = { - ...state.ui, - perServerSelections: setServerSelection(state, serverId, { - selectedChannelId: getCurrentSelection(state).selectedChannelId, - selectedPrivateChatId: null, - }), - }; - } - - // Update localStorage if it was pinned - if (privateChat?.isPinned) { - const pinnedChats = loadPinnedPrivateChats(); - if (pinnedChats[serverId]) { - pinnedChats[serverId] = pinnedChats[serverId].filter( - (pc) => pc.username !== privateChat.username, - ); - savePinnedPrivateChats(pinnedChats); - } - } - - return newState; - }); - }, - - pinPrivateChat: (serverId, privateChatId) => { - set((state) => { - const server = state.servers.find((s) => s.id === serverId); - if (!server) return {}; - - const privateChat = server.privateChats?.find( - (pc) => pc.id === privateChatId, - ); - if (!privateChat) return {}; - - // Calculate the new order (highest + 1) - const maxOrder = Math.max( - 0, - ...(server.privateChats - ?.filter((pc) => pc.isPinned && pc.order !== undefined) - .map((pc) => pc.order as number) || []), - ); - - const updatedServers = state.servers.map((s) => { - if (s.id === serverId) { - const updatedPrivateChats = s.privateChats?.map((pc) => { - if (pc.id === privateChatId) { - return { ...pc, isPinned: true, order: maxOrder + 1 }; - } - return pc; - }); - return { ...s, privateChats: updatedPrivateChats }; - } - return s; - }); - - // Save to localStorage - const pinnedChats = loadPinnedPrivateChats(); - if (!pinnedChats[serverId]) { - pinnedChats[serverId] = []; - } - pinnedChats[serverId].push({ - username: privateChat.username, - order: maxOrder + 1, - }); - savePinnedPrivateChats(pinnedChats); - - return { servers: updatedServers }; - }); - }, - - unpinPrivateChat: (serverId, privateChatId) => { - set((state) => { - const server = state.servers.find((s) => s.id === serverId); - if (!server) return {}; - - const privateChat = server.privateChats?.find( - (pc) => pc.id === privateChatId, - ); - if (!privateChat) return {}; - - const updatedServers = state.servers.map((s) => { - if (s.id === serverId) { - const updatedPrivateChats = s.privateChats?.map((pc) => { - if (pc.id === privateChatId) { - return { ...pc, isPinned: false, order: undefined }; - } - return pc; - }); - return { ...s, privateChats: updatedPrivateChats }; - } - return s; - }); - - // Remove from localStorage - const pinnedChats = loadPinnedPrivateChats(); - if (pinnedChats[serverId]) { - pinnedChats[serverId] = pinnedChats[serverId].filter( - (pc) => pc.username !== privateChat.username, - ); - savePinnedPrivateChats(pinnedChats); - } - - return { servers: updatedServers }; - }); - }, - - reorderPrivateChats: (serverId, privateChatIds) => { - set((state) => { - const server = state.servers.find((s) => s.id === serverId); - if (!server) return {}; - - // Update order for each private chat - const updatedServers = state.servers.map((s) => { - if (s.id === serverId) { - const updatedPrivateChats = s.privateChats?.map((pc) => { - const newOrder = privateChatIds.indexOf(pc.id); - if (newOrder !== -1 && pc.isPinned) { - return { ...pc, order: newOrder }; - } - return pc; - }); - return { ...s, privateChats: updatedPrivateChats }; - } - return s; - }); - - // Save to localStorage - const pinnedChats = loadPinnedPrivateChats(); - if (pinnedChats[serverId]) { - // Update order for all pinned chats - pinnedChats[serverId] = pinnedChats[serverId].map((pc) => { - const privateChat = server.privateChats?.find( - (p) => p.username === pc.username, - ); - if (privateChat) { - const newOrder = privateChatIds.indexOf(privateChat.id); - if (newOrder !== -1) { - return { ...pc, order: newOrder }; - } - } - return pc; - }); - savePinnedPrivateChats(pinnedChats); - } - - return { servers: updatedServers }; - }); - }, - - connectToSavedServers: async () => { - const state = get(); - if (state.hasConnectedToSavedServers) { - return; // Already connected, don't do it again - } - - set({ hasConnectedToSavedServers: true }); - - const savedServers = loadSavedServers(); - for (const savedServer of savedServers) { - const { - id, - name, - host, - port, - nickname, - password, - channels, - saslEnabled, - saslAccountName, - saslPassword, - } = savedServer; - - // Check if server already exists in store - const existingServer = get().servers.find( - (s) => s.host === host && s.port === port, - ); - - if (!existingServer) { - // Add server to store with connecting state - const connectingServer: Server = { - id, - name: name || host, - host, - port, - channels: [], - privateChats: [], - isConnected: false, - connectionState: "connecting", - users: [], - }; - - set((state) => ({ - servers: [...state.servers, connectingServer], - })); - } - - try { - await get().connect( - name || host, - host, - port, - nickname, - saslEnabled, - password, - saslAccountName, - saslPassword, - ); - } catch (error) { - console.error(`Failed to reconnect to server ${host}:${port}`, error); - // Update server state to disconnected - set((state) => ({ - servers: state.servers.map((s) => - s.host === host && s.port === port - ? { ...s, connectionState: "disconnected" as const } - : s, - ), - })); - } - } - }, - - reconnectServer: async (serverId: string) => { - const state = get(); - const server = state.servers.find((s) => s.id === serverId); - if (!server) { - console.error(`Server ${serverId} not found`); - return; - } - - // Update server state to connecting - set((state) => ({ - servers: state.servers.map((s) => - s.id === serverId - ? { ...s, connectionState: "connecting" as const } - : s, - ), - })); - - try { - // Get saved server config to get credentials - const savedServers = loadSavedServers(); - const savedServer = savedServers.find( - (s) => s.host === server.host && s.port === server.port, - ); - - if (!savedServer) { - console.error(`No saved configuration found for server ${serverId}`, { - host: server.host, - port: server.port, - savedServers, - }); - throw new Error(`No saved configuration found for server ${serverId}`); - } - - await get().connect( - savedServer.name || savedServer.host, - savedServer.host, - savedServer.port, - savedServer.nickname, - savedServer.saslEnabled, - savedServer.password, - savedServer.saslAccountName, - savedServer.saslPassword, - ); - } catch (error) { - console.error(`Failed to reconnect to server ${serverId}`, error); - // Update server state back to disconnected - set((state) => ({ - servers: state.servers.map((s) => - s.id === serverId - ? { ...s, connectionState: "disconnected" as const } - : s, - ), - })); - } - }, - - deleteServer: (serverId) => { - set((state) => { - const serverToDelete = state.servers.find( - (server) => server.id === serverId, - ); - - // Remove server from localStorage - const savedServers = loadSavedServers(); - const updatedServers = savedServers.filter( - (s) => - s.host !== serverToDelete?.host || s.port !== serverToDelete?.port, - ); - saveServersToLocalStorage(updatedServers); - - // Remove server's metadata from localStorage - const savedMetadata = loadSavedMetadata(); - delete savedMetadata[serverId]; - saveMetadataToLocalStorage(savedMetadata); - - // Update state - const remainingServers = state.servers.filter( - (server) => server.id !== serverId, - ); - const newSelectedServerId = - remainingServers.length > 0 ? remainingServers[0].id : null; - - return { - servers: remainingServers, - ui: { - ...state.ui, - selectedServerId: newSelectedServerId, - selectedChannelId: newSelectedServerId - ? remainingServers[0].channels[0]?.id || null - : null, - }, - }; - }); - - ircClient.disconnect(serverId); - }, - - updateServer: (serverId, config) => { - const savedServers = loadSavedServers(); - const serverIndex = savedServers.findIndex((s) => s.id === serverId); - - if (serverIndex !== -1) { - savedServers[serverIndex] = { ...savedServers[serverIndex], ...config }; - saveServersToLocalStorage(savedServers); - } - }, - - // UI actions - toggleAddServerModal: (isOpen, prefillDetails = null) => { - set((state) => ({ - ui: { - ...state.ui, - isAddServerModalOpen: isOpen, - prefillServerDetails: prefillDetails, - }, - })); - }, - - toggleEditServerModal: (isOpen, serverId = null) => { - set((state) => ({ - ui: { - ...state.ui, - isEditServerModalOpen: isOpen ?? false, - editServerId: serverId, - }, - })); - }, - - toggleSettingsModal: (isOpen) => { - set((state) => ({ - ui: { - ...state.ui, - isSettingsModalOpen: - isOpen !== undefined ? isOpen : !state.ui.isSettingsModalOpen, - }, - })); - }, - - toggleUserProfileModal: (isOpen) => { - set((state) => ({ - ui: { - ...state.ui, - isUserProfileModalOpen: - isOpen !== undefined ? isOpen : !state.ui.isUserProfileModalOpen, - }, - })); - }, - - setProfileViewRequest: (serverId, username) => { - set((state) => ({ - ui: { - ...state.ui, - profileViewRequest: { serverId, username }, - }, - })); - }, - - clearProfileViewRequest: () => { - set((state) => ({ - ui: { - ...state.ui, - profileViewRequest: null, - }, - })); - }, - - toggleDarkMode: () => { - set((state) => ({ - ui: { - ...state.ui, - isDarkMode: !state.ui.isDarkMode, - }, - })); - }, - - toggleMobileMenu: (isOpen) => { - set((state) => ({ - ui: { - ...state.ui, - isMobileMenuOpen: - isOpen !== undefined ? isOpen : !state.ui.isMobileMenuOpen, - }, - })); - }, - - toggleMemberList: (isOpen) => { - set((state) => { - const openState = - isOpen !== undefined ? isOpen : !state.ui.isChannelListVisible; - - // Only change mobileViewActiveColumn if we're not on the serverList view - // This prevents desktop member list toggles from affecting mobile navigation - const shouldUpdateMobileColumn = - state.ui.mobileViewActiveColumn !== "serverList"; - - return { - ui: { - ...state.ui, - isMemberListVisible: - openState !== undefined ? openState : !state.ui.isMemberListVisible, - mobileViewActiveColumn: shouldUpdateMobileColumn - ? openState - ? "memberList" - : "chatView" - : state.ui.mobileViewActiveColumn, - }, - }; - }); - }, - - toggleChannelList: (isOpen) => { - set((state) => { - const openState = - isOpen !== undefined ? isOpen : !state.ui.isChannelListVisible; - return { - ui: { - ...state.ui, - isChannelListVisible: openState, - mobileViewActiveColumn: openState - ? "serverList" - : state.ui.mobileViewActiveColumn, - }, - }; - }); - }, - - toggleChannelListModal: (isOpen) => { - set((state) => ({ - ui: { - ...state.ui, - isChannelListModalOpen: - isOpen !== undefined ? isOpen : !state.ui.isChannelListModalOpen, - }, - })); - }, - - toggleChannelRenameModal: (isOpen?: boolean) => { - set((state) => ({ - ui: { - ...state.ui, - isChannelRenameModalOpen: - isOpen !== undefined ? isOpen : !state.ui.isChannelRenameModalOpen, - }, - })); - }, - - toggleServerMenu: (isOpen) => { - set((state) => ({ - ui: { - ...state.ui, - isServerMenuOpen: - isOpen !== undefined ? isOpen : !state.ui.isServerMenuOpen, - }, - })); - }, - - showContextMenu: (x, y, type, itemId) => { - set((state) => ({ - ui: { - ...state.ui, - contextMenu: { - isOpen: true, - x, - y, - type, - itemId, - }, - }, - })); - }, - - hideContextMenu: () => { - set((state) => ({ - ui: { - ...state.ui, - contextMenu: { - ...state.ui.contextMenu, - isOpen: false, - }, - }, - })); - }, - - setMobileViewActiveColumn: (column: layoutColumn) => { - set((state) => ({ - ui: { - ...state.ui, - mobileViewActiveColumn: column, - }, - })); - }, - - toggleServerNoticesPopup: (isOpen) => { - set((state) => ({ - ui: { - ...state.ui, - isServerNoticesPopupOpen: - isOpen !== undefined ? isOpen : !state.ui.isServerNoticesPopupOpen, - serverNoticesPopupMinimized: false, // Reset minimized state when toggling - }, - })); - }, - - minimizeServerNoticesPopup: (isMinimized) => { - set((state) => ({ - ui: { - ...state.ui, - serverNoticesPopupMinimized: - isMinimized !== undefined - ? isMinimized - : !state.ui.serverNoticesPopupMinimized, - }, - })); - }, - - triggerServerShimmer: (serverId) => { - set((state) => { - const newShimmer = new Set(state.ui.serverShimmer); - newShimmer.add(serverId); - return { - ui: { - ...state.ui, - serverShimmer: newShimmer, - }, - }; - }); - // Clear shimmer after animation duration (e.g., 2 seconds) - setTimeout(() => { - get().clearServerShimmer(serverId); - }, 2000); - }, - - clearServerShimmer: (serverId) => { - set((state) => { - const newShimmer = new Set(state.ui.serverShimmer); - newShimmer.delete(serverId); - return { - ui: { - ...state.ui, - serverShimmer: newShimmer, - }, - }; - }); - }, - - // Settings actions - updateGlobalSettings: (settings: Partial) => { - set((state) => { - const newGlobalSettings = { - ...state.globalSettings, - ...settings, - }; - // Save to localStorage - saveGlobalSettingsToLocalStorage(newGlobalSettings); - return { - globalSettings: newGlobalSettings, - }; - }); - }, - - // Ignore list actions - addToIgnoreList: (pattern: string) => { - set((state) => { - const trimmedPattern = pattern.trim(); - if ( - !trimmedPattern || - state.globalSettings.ignoreList.includes(trimmedPattern) - ) { - return state; - } - - const newIgnoreList = [ - ...state.globalSettings.ignoreList, - trimmedPattern, - ]; - const newGlobalSettings = { - ...state.globalSettings, - ignoreList: newIgnoreList, - }; - - // Save to localStorage - saveGlobalSettingsToLocalStorage(newGlobalSettings); - - return { - globalSettings: newGlobalSettings, - }; - }); - }, - - removeFromIgnoreList: (pattern: string) => { - set((state) => { - const newIgnoreList = state.globalSettings.ignoreList.filter( - (p) => p !== pattern, - ); - const newGlobalSettings = { - ...state.globalSettings, - ignoreList: newIgnoreList, - }; - - // Save to localStorage - saveGlobalSettingsToLocalStorage(newGlobalSettings); - - return { - globalSettings: newGlobalSettings, - }; - }); - }, - - // Attachment actions - addInputAttachment: (attachment: Attachment) => { - set((state) => ({ - ui: { - ...state.ui, - inputAttachments: [...state.ui.inputAttachments, attachment], - }, - })); - }, - - removeInputAttachment: (attachmentId: string) => { - set((state) => ({ - ui: { - ...state.ui, - inputAttachments: state.ui.inputAttachments.filter( - (att) => att.id !== attachmentId, - ), - }, - })); - }, - - clearInputAttachments: () => { - set((state) => ({ - ui: { - ...state.ui, - inputAttachments: [], - }, - })); - }, - - // Metadata actions - metadataGet: (serverId, target, keys) => { - if (serverSupportsMetadata(serverId)) { - ircClient.metadataGet(serverId, target, keys); - } - }, - - metadataList: (serverId, target) => { - if (!serverSupportsMetadata(serverId)) { - return; - } - - // Check if we've already requested metadata for this user - const requestedUsers = get().userMetadataRequested[serverId] || new Set(); - if (requestedUsers.has(target)) { - return; // Already requested - } - - // Check if we already have metadata for this user - const savedMetadata = loadSavedMetadata(); - const serverMetadata = savedMetadata[serverId]; - if ( - serverMetadata?.[target] && - Object.keys(serverMetadata[target]).length > 0 - ) { - // We already have metadata, mark as requested to avoid future requests - set((state) => ({ - userMetadataRequested: { - ...state.userMetadataRequested, - [serverId]: new Set([ - ...(state.userMetadataRequested[serverId] || []), - target, - ]), - }, - })); - return; // No need to request - } - - // Check if user is in any channel and has metadata there - const server = get().servers.find((s) => s.id === serverId); - if (server) { - for (const channel of server.channels) { - const user = channel.users.find( - (u) => u.username.toLowerCase() === target.toLowerCase(), - ); - if (user?.metadata && Object.keys(user.metadata).length > 0) { - // We already have metadata, mark as requested - set((state) => ({ - userMetadataRequested: { - ...state.userMetadataRequested, - [serverId]: new Set([ - ...(state.userMetadataRequested[serverId] || []), - target, - ]), - }, - })); - return; // No need to request - } - } - } - - // Mark as requested and fetch metadata - set((state) => ({ - userMetadataRequested: { - ...state.userMetadataRequested, - [serverId]: new Set([ - ...(state.userMetadataRequested[serverId] || []), - target, - ]), - }, - })); - - ircClient.metadataList(serverId, target); - }, - - metadataSet: (serverId, target, key, value, visibility) => { - if (serverSupportsMetadata(serverId)) { - ircClient.metadataSet(serverId, target, key, value, visibility); - } - }, - - metadataClear: (serverId, target) => { - if (serverSupportsMetadata(serverId)) { - ircClient.metadataClear(serverId, target); - } - }, - - metadataSub: (serverId, keys) => { - if (serverSupportsMetadata(serverId)) { - console.log( - `[METADATA_SUB] Subscribing to keys for server ${serverId}:`, - keys, - ); - ircClient.metadataSub(serverId, keys); - } else { - } - }, - - metadataUnsub: (serverId, keys) => { - if (serverSupportsMetadata(serverId)) { - ircClient.metadataUnsub(serverId, keys); - } - }, - - metadataSubs: (serverId) => { - if (serverSupportsMetadata(serverId)) { - ircClient.metadataSubs(serverId); - } - }, - - metadataSync: (serverId, target) => { - if (serverSupportsMetadata(serverId)) { - ircClient.metadataSync(serverId, target); - } - }, - - sendRaw: (serverId, command) => { - ircClient.sendRaw(serverId, command); - }, - - capAck: (serverId, key, capabilities) => { - ircClient.capAck(serverId, key, capabilities); - }, -})); - -// Initialize protocol handlers -registerAllProtocolHandlers(ircClient, useStore); - -// Set up event listeners for IRC client events -// -// TODO: We should have actual events here, The commended ones are never fired and seems to be causing a bug with the state management -// ircClient.on( -// "message", -// (response: { serverId: string; channelId: string; message: Message }) => { -// const { serverId, channelId, message } = response; -// useStore.getState().addMessage(message); -// }, -// ); - -// ircClient.on("system_message", (response: { message: Message }) => { -// const { message } = response; -// useStore.getState().addMessage(message); -// }); - -// ircClient.on("connect", (response: { servers: Server[] }) => { -// const { servers } = response; -// useStore.setState({ servers }); -// }); - -// ircClient.on("disconnect", (response: { serverId: string }) => { -// const { serverId } = response; -// if (serverId) { -// // Update specific server status -// useStore.setState((state) => ({ -// servers: state.servers.map((server) => -// server.id === serverId ? { ...server, isConnected: false } : server, -// ), -// })); -// } else { -// // Refresh servers list -// const servers = ircClient.getServers(); -// useStore.setState({ servers }); -// } -// }); - -ircClient.on("connectionStateChange", ({ serverId, connectionState }) => { - useStore.setState((state) => { - const updatedServers = state.servers.map((server) => - server.id === serverId - ? { - ...server, - connectionState, - isConnected: connectionState === "connected", - } - : server, - ); - - // If a server just connected and we have no selected server (showing welcome screen), - // switch back to this server to maintain continuity during reconnection - let newUi = { ...state.ui }; - if (connectionState === "connected" && state.ui.selectedServerId === null) { - const reconnectedServer = updatedServers.find((s) => s.id === serverId); - if (reconnectedServer) { - const serverSelection = getServerSelection(state, serverId); - newUi = { - ...newUi, - selectedServerId: serverId, - perServerSelections: setServerSelection( - state, - serverId, - serverSelection, - ), - }; - } - } - - return { - servers: updatedServers, - ui: newUi, - }; - }); -}); - -ircClient.on("CHANMSG", (response) => { - const { mtags, channelName, message, timestamp } = response; - - // Check for duplicate messages based on msgid - if (mtags?.msgid) { - const currentState = useStore.getState(); - if (currentState.processedMessageIds.has(mtags.msgid)) { - console.log(`Skipping duplicate message with msgid: ${mtags.msgid}`); - return; - } - } - - // Check if sender is ignored - const globalSettings = useStore.getState().globalSettings; - if ( - isUserIgnored( - response.sender, - undefined, - undefined, - globalSettings.ignoreList, - ) - ) { - // User is ignored, skip processing this message - return; - } - - // Find the server and channel - const server = useStore - .getState() - .servers.find((s) => s.id === response.serverId); - - if (server) { - const channel = server.channels.find( - (c) => c.name.toLowerCase() === channelName.toLowerCase(), - ); - const replyTo = null; - - if (channel) { - const replyId = mtags?.["+draft/reply"] - ? mtags["+draft/reply"].trim() - : null; - - const replyMessage = replyId - ? findChannelMessageById(server.id, channel.id, replyId) || null - : null; - - // Check for mentions and get current state - const currentState = useStore.getState(); - const currentServerUser = ircClient.getCurrentUser(response.serverId); - // Don't trigger mentions for our own messages - const isOwnMessage = response.sender === currentServerUser?.username; - const hasMention = - !isOwnMessage && - checkForMention( - message, - currentServerUser, - currentState.globalSettings, - ); - const mentions = !isOwnMessage - ? extractMentions( - message, - currentServerUser, - currentState.globalSettings, - ) - : []; - - const newMessage = { - id: uuidv4(), - msgid: mtags?.msgid, - content: message, - timestamp, - userId: response.sender, - channelId: channel.id, - serverId: server.id, - type: "message" as const, - reactions: [], - replyMessage: replyMessage, - mentioned: mentions, - tags: mtags, - }; - - // Update channel unread count and mention flag if not the active channel - const isActiveChannel = - getCurrentSelection(currentState).selectedChannelId === channel.id && - currentState.ui.selectedServerId === server.id; - - // Don't count unread/mentions for historical messages (batch tag indicates chathistory playback) - const isHistoricalMessage = mtags?.batch !== undefined; - - if ( - !isActiveChannel && - response.sender !== currentServerUser?.username && - !isHistoricalMessage - ) { - useStore.setState((state) => { - const updatedServers = state.servers.map((s) => { - if (s.id === server.id) { - const updatedChannels = s.channels.map((ch) => { - if (ch.id === channel.id) { - return { - ...ch, - unreadCount: ch.unreadCount + 1, - isMentioned: hasMention || ch.isMentioned, - }; - } - return ch; - }); - return { ...s, channels: updatedChannels }; - } - return s; - }); - return { servers: updatedServers }; - }); - - // Show browser notification for mentions - if (hasMention && currentState.globalSettings.enableNotifications) { - showMentionNotification( - server.id, - channelName, - response.sender, - message, - (serverId, msg) => { - // Fallback: Add a NOTE standard reply notification - useStore.getState().addGlobalNotification({ - type: "note", - command: "MENTION", - code: "HIGHLIGHT", - message: msg, - serverId, - }); - }, - ); - } - } - - // If message has bot tag, mark user as bot - if (mtags?.bot !== undefined) { - useStore.setState((state) => { - const updatedServers = state.servers.map((s) => { - if (s.id === server.id) { - const updatedChannels = s.channels.map((channel) => { - const updatedUsers = channel.users.map((user) => { - if (user.username === response.sender) { - return { - ...user, - isBot: true, // Set bot flag from message tags - metadata: { - ...user.metadata, - bot: { value: "true", visibility: "public" }, - }, - }; - } - return user; - }); - return { ...channel, users: updatedUsers }; - }); - return { ...s, channels: updatedChannels }; - } - return s; - }); - return { servers: updatedServers }; - }); - } - - useStore.getState().addMessage(newMessage); - - // Play notification sound if appropriate (but not for historical messages) - if (!isHistoricalMessage) { - const state = useStore.getState(); - const serverCurrentUser = ircClient.getCurrentUser(response.serverId); - if ( - shouldPlayNotificationSound( - newMessage, - serverCurrentUser, - state.globalSettings, - ) - ) { - playNotificationSound(state.globalSettings); - } - } - - // Mark this message ID as processed to prevent duplicates - if (mtags?.msgid) { - useStore.setState((state) => ({ - processedMessageIds: new Set([ - ...state.processedMessageIds, - mtags.msgid, - ]), - })); - } - - // Remove any typing users from the state - useStore.setState((state) => { - const key = `${server.id}-${channel.id}`; - const currentUsers = state.typingUsers[key] || []; - return { - typingUsers: { - ...state.typingUsers, - [key]: currentUsers.filter((u) => u.username !== response.sender), - }, - }; - }); - } - } -}); - -// Handle multiline messages -ircClient.on("MULTILINE_MESSAGE", (response) => { - const { mtags, channelName, sender, message, messageIds, timestamp } = - response; - - // Check for duplicate messages based on messageIds - if (messageIds && messageIds.length > 0) { - const currentState = useStore.getState(); - const hasDuplicate = messageIds.some((id) => - currentState.processedMessageIds.has(id), - ); - if (hasDuplicate) { - console.log( - `Skipping duplicate multiline message with messageIds: ${messageIds.join(", ")}`, - ); - return; - } - } - - // Check if sender is ignored - const globalSettings = useStore.getState().globalSettings; - if (isUserIgnored(sender, undefined, undefined, globalSettings.ignoreList)) { - // User is ignored, skip processing this message - return; - } - - // Find the server and channel - const server = useStore - .getState() - .servers.find((s) => s.id === response.serverId); - - if (server) { - const channel = channelName - ? server.channels.find( - (c) => c.name.toLowerCase() === channelName.toLowerCase(), - ) - : null; - - if (channel) { - const replyId = mtags?.["+draft/reply"] - ? mtags["+draft/reply"].trim() - : null; - - const replyMessage = replyId - ? findChannelMessageById(server.id, channel.id, replyId) || null - : null; - - const newMessage = { - id: uuidv4(), - msgid: mtags?.msgid, - multilineMessageIds: messageIds, // Store all message IDs for redaction - content: message, // Use the properly combined message from IRC client - timestamp, - userId: sender, - channelId: channel.id, - serverId: server.id, - type: "message" as const, - reactions: [], - replyMessage: replyMessage, - mentioned: [], // Add logic for mentions if needed - tags: mtags, - }; - - // If message has bot tag, mark user as bot - if (mtags?.bot !== undefined) { - useStore.setState((state) => { - const updatedServers = state.servers.map((s) => { - if (s.id === server.id) { - const updatedChannels = s.channels.map((channel) => { - const updatedUsers = channel.users.map((user) => { - if (user.username === sender) { - return { - ...user, - isBot: true, - }; - } - return user; - }); - return { ...channel, users: updatedUsers }; - }); - return { ...s, channels: updatedChannels }; - } - return s; - }); - return { servers: updatedServers }; - }); - } - - // Mark these message IDs as processed to prevent duplicates - if (messageIds && messageIds.length > 0) { - useStore.setState((state) => ({ - processedMessageIds: new Set([ - ...state.processedMessageIds, - ...messageIds, - ]), - })); - } - - useStore.getState().addMessage(newMessage); - - // Play notification sound if appropriate (but not for historical messages) - // Don't count unread/mentions for historical messages (batch tag indicates chathistory playback) - const isHistoricalMessage = mtags?.batch !== undefined; - - if (!isHistoricalMessage) { - const state = useStore.getState(); - const serverCurrentUser = ircClient.getCurrentUser(response.serverId); - if ( - shouldPlayNotificationSound( - newMessage, - serverCurrentUser, - state.globalSettings, - ) - ) { - playNotificationSound(state.globalSettings); - } - } - - // Remove any typing users from the state - useStore.setState((state) => { - const key = `${server.id}-${channel.id}`; - const currentUsers = state.typingUsers[key] || []; - return { - typingUsers: { - ...state.typingUsers, - [key]: currentUsers.filter((u) => u.username !== sender), - }, - }; - }); - } else if (!channelName) { - // Handle multiline private messages - // Similar logic to USERMSG but for multiline content - const currentUser = ircClient.getCurrentUser(response.serverId); - if (currentUser && sender === currentUser.username) { - return; // Don't create private chats with ourselves - } - - // Create or find private chat - let privateChat = server.privateChats.find( - (chat) => chat.username === sender, - ); - if (!privateChat) { - const newPrivateChat = { - id: uuidv4(), - username: sender, - serverId: server.id, - unreadCount: 0, - isMentioned: false, - lastActivity: new Date(), - isPinned: false, - order: undefined, - isOnline: false, // Will be updated by MONITOR - isAway: false, - }; - privateChat = newPrivateChat; - useStore.setState((state) => ({ - servers: state.servers.map((s) => - s.id === server.id - ? { ...s, privateChats: [...s.privateChats, newPrivateChat] } - : s, - ), - })); - } - - const newMessage = { - id: uuidv4(), - msgid: mtags?.msgid, - multilineMessageIds: messageIds, // Store all message IDs for redaction - content: message, // Use the properly combined message from IRC client - timestamp, - userId: sender, - channelId: privateChat.id, - serverId: server.id, - type: "message" as const, - reactions: [], - replyMessage: null, - mentioned: [], - tags: mtags, - }; - - useStore.getState().addMessage(newMessage); - - // Play notification sound if appropriate (but not for historical messages) - // Don't count unread/mentions for historical messages (batch tag indicates chathistory playback) - const isHistoricalMessage = mtags?.batch !== undefined; - - if (!isHistoricalMessage) { - const state = useStore.getState(); - const serverCurrentUser = ircClient.getCurrentUser(response.serverId); - if ( - shouldPlayNotificationSound( - newMessage, - serverCurrentUser, - state.globalSettings, - ) - ) { - playNotificationSound(state.globalSettings); - } - } - } - } -}); - -// Handle private messages (USERMSG) -ircClient.on("USERMSG", (response) => { - const { mtags, sender, target, message, timestamp } = response; - - console.log("[USERMSG] Received:", { - sender, - target, - message, - channelContext: mtags?.["+draft/channel-context"], - }); - - // Check for duplicate messages based on msgid - if (mtags?.msgid) { - const currentState = useStore.getState(); - if (currentState.processedMessageIds.has(mtags.msgid)) { - console.log(`Skipping duplicate USERMSG with msgid: ${mtags.msgid}`); - return; - } - } - - // Find the server - const server = useStore - .getState() - .servers.find((s) => s.id === response.serverId); - - if (server) { - // Check if this PRIVMSG is from the server itself (sender contains a ".") - // Server messages should go to Server Notices, not create PM tabs - if (sender.includes(".")) { - console.log( - "[USERMSG] Server message detected, routing to Server Notices:", - sender, - ); - - const targetChannelId = "server-notices"; - const newMessage: Message = { - id: uuidv4(), - type: "notice", - content: message, - timestamp: timestamp, - userId: sender, - channelId: targetChannelId, - serverId: server.id, - reactions: [], - replyMessage: null, - mentioned: [], - tags: mtags, - }; - - useStore.getState().addMessage(newMessage); - - // Play notification sound if appropriate (but not for historical messages) - const isHistoricalMessage = mtags?.batch !== undefined; - if (!isHistoricalMessage) { - const state = useStore.getState(); - const serverCurrentUser = ircClient.getCurrentUser(response.serverId); - if ( - shouldPlayNotificationSound( - newMessage, - serverCurrentUser, - state.globalSettings, - ) - ) { - playNotificationSound(state.globalSettings); - } - } - - return; // Don't process as a regular PM - } - - // Check if this is a whisper (has draft/channel-context tag) - // Note: Client tags use + prefix, so check both with and without - const channelContext = mtags?.["+draft/channel-context"]; - - if (channelContext) { - console.log("[WHISPER] Detected channel-context tag:", channelContext); - console.log( - "[WHISPER] Available channels:", - server.channels.map((c) => c.name), - ); - - // This is a whisper - route it to the channel specified in the tag - // Use case-insensitive matching - const channel = server.channels.find( - (c) => c.name.toLowerCase() === channelContext.toLowerCase(), - ); - - console.log( - "[WHISPER] Found channel:", - channel ? channel.name : "NOT FOUND", - ); - - if (channel) { - const replyId = mtags?.["+draft/reply"] - ? mtags["+draft/reply"].trim() - : null; - - const replyMessage = replyId - ? findChannelMessageById(server.id, channel.id, replyId) || null - : null; - - const newMessage = { - id: uuidv4(), - msgid: mtags?.msgid, - content: message, - timestamp, - userId: sender, - channelId: channel.id, - serverId: server.id, - type: "message" as const, - reactions: [], - replyMessage: replyMessage, - mentioned: [], - tags: mtags, // This includes the draft/channel-context tag - whisperTarget: target, // Store the recipient for display - }; - - // Mark this message ID as processed to prevent duplicates - if (mtags?.msgid) { - useStore.setState((state) => ({ - processedMessageIds: new Set([ - ...state.processedMessageIds, - mtags.msgid, - ]), - })); - } - - useStore.getState().addMessage(newMessage); - - // Play notification sound if appropriate (only if it's not from ourselves and not historical) - const currentUser = ircClient.getCurrentUser(response.serverId); - const isHistoricalMessage = mtags?.batch !== undefined; - - if (currentUser?.username !== sender && !isHistoricalMessage) { - const state = useStore.getState(); - const serverCurrentUser = ircClient.getCurrentUser(response.serverId); - if ( - shouldPlayNotificationSound( - newMessage, - serverCurrentUser, - state.globalSettings, - ) - ) { - playNotificationSound(state.globalSettings); - } - } - - return; // Early return - don't create a private chat - } - } - } - - // Don't create private chats with ourselves when the server echoes back our own messages - const currentUser = ircClient.getCurrentUser(response.serverId); - if (currentUser?.username === sender) { - return; - } - - // Check if sender is ignored - const globalSettings = useStore.getState().globalSettings; - if (isUserIgnored(sender, undefined, undefined, globalSettings.ignoreList)) { - // User is ignored, skip processing this message - return; - } - - if (server) { - // Find or create private chat - let privateChat = server.privateChats?.find((pc) => pc.username === sender); - - if (!privateChat) { - // Auto-create private chat when receiving a message - useStore.getState().openPrivateChat(server.id, sender); - // Get the newly created private chat - privateChat = useStore - .getState() - .servers.find((s) => s.id === server.id) - ?.privateChats?.find((pc) => pc.username === sender); - } - - if (privateChat) { - const newMessage = { - id: uuidv4(), - msgid: mtags?.msgid, - content: message, - timestamp, - userId: sender, - channelId: privateChat.id, // Use private chat ID as channel ID - serverId: server.id, - type: "message" as const, - reactions: [], - replyMessage: null, - mentioned: [], // PMs don't have mentions in the traditional sense - tags: mtags, - }; - - // If message has bot tag, mark user as bot - if (mtags?.bot !== undefined) { - useStore.setState((state) => { - const updatedServers = state.servers.map((s) => { - if (s.id === server.id) { - const updatedChannels = s.channels.map((channel) => { - const updatedUsers = channel.users.map((user) => { - if (user.username === sender) { - return { - ...user, - isBot: true, // Set bot flag from message tags - metadata: { - ...user.metadata, - bot: { value: "true", visibility: "public" }, - }, - }; - } - return user; - }); - return { ...channel, users: updatedUsers }; - }); - return { ...s, channels: updatedChannels }; - } - return s; - }); - return { servers: updatedServers }; - }); - } - - // Mark this message ID as processed to prevent duplicates - if (mtags?.msgid) { - useStore.setState((state) => ({ - processedMessageIds: new Set([ - ...state.processedMessageIds, - mtags.msgid, - ]), - })); - } - - useStore.getState().addMessage(newMessage); - - // Remove any typing users from the state - useStore.setState((state) => { - const key = `${server.id}-${privateChat.id}`; - const currentUsers = state.typingUsers[key] || []; - return { - typingUsers: { - ...state.typingUsers, - [key]: currentUsers.filter((u) => u.username !== sender), - }, - }; - }); - - // Update private chat's last activity and unread count - // Don't count unread/mentions for historical messages (batch tag indicates chathistory playback) - const isHistoricalMessage = mtags?.batch !== undefined; - - // Play notification sound if appropriate (but not for historical messages) - if (!isHistoricalMessage) { - const state = useStore.getState(); - const serverCurrentUser = ircClient.getCurrentUser(response.serverId); - if ( - shouldPlayNotificationSound( - newMessage, - serverCurrentUser, - state.globalSettings, - ) - ) { - playNotificationSound(state.globalSettings); - } - } - - useStore.setState((state) => { - const updatedServers = state.servers.map((s) => { - if (s.id === response.serverId) { - const updatedPrivateChats = - s.privateChats?.map((pc) => { - if (pc.id === privateChat.id) { - const isActive = - getCurrentSelection(state).selectedPrivateChatId === pc.id; - return { - ...pc, - lastActivity: new Date(), - unreadCount: - isActive || isHistoricalMessage ? 0 : pc.unreadCount + 1, - isMentioned: !isHistoricalMessage && true, // All PMs are considered mentions (except historical) - }; - } - return pc; - }) || []; - return { ...s, privateChats: updatedPrivateChats }; - } - return s; - }); - return { servers: updatedServers }; - }); - - // Show browser notification for private messages - const currentState = useStore.getState(); - const isActiveChat = - getCurrentSelection(currentState).selectedPrivateChatId === - privateChat.id; - if ( - !isActiveChat && - !isHistoricalMessage && - currentState.globalSettings.enableNotifications - ) { - showMentionNotification( - server.id, - `DM from ${sender}`, - sender, - message, - (serverId, msg) => { - // Fallback: Add a NOTE standard reply notification - useStore.getState().addGlobalNotification({ - type: "note", - command: "PRIVMSG", - code: "DM", - message: msg, - serverId, - }); - }, - ); - } - } - } -}); - -ircClient.on("CHANNNOTICE", (response) => { - const { mtags, channelName, message, timestamp } = response; - - // Check for duplicate messages based on msgid - if (mtags?.msgid) { - const currentState = useStore.getState(); - if (currentState.processedMessageIds.has(mtags.msgid)) { - console.log(`Skipping duplicate CHANNNOTICE with msgid: ${mtags.msgid}`); - return; - } - } - - // Check if sender is ignored - const globalSettings = useStore.getState().globalSettings; - if ( - isUserIgnored( - response.sender, - undefined, - undefined, - globalSettings.ignoreList, - ) - ) { - // User is ignored, skip processing this notice - return; - } - - // Find the server - const server = useStore - .getState() - .servers.find((s) => s.id === response.serverId); - - if (!server) return; - - // Check if this is a JSON log notice - const isJsonLog = mtags?.["unrealircd.org/json-log"]; - let jsonLogData = null; - if (isJsonLog) { - try { - const jsonString = mtags["unrealircd.org/json-log"]; - // Log the raw JSON string for debugging (first 200 chars) - console.log( - "Raw JSON log data:", - jsonString.substring(0, 200) + (jsonString.length > 200 ? "..." : ""), - ); - jsonLogData = JSON.parse(jsonString); - } catch (error) { - console.error("Failed to parse JSON log:", error); - console.error("Raw JSON string was:", mtags["unrealircd.org/json-log"]); - // Try to clean up common issues - try { - const cleanedJson = mtags["unrealircd.org/json-log"] - // Replace all \s with spaces (UnrealIRCd uses \s as non-standard space escape) - .replace(/\\s/g, " ") - // Handle other potential escape issues - .replace(/\\'/g, "'") - .replace(/\\&/g, "&"); - - jsonLogData = JSON.parse(cleanedJson); - console.log("Successfully parsed after cleanup"); - } catch (cleanupError) { - console.error("Failed to parse even after cleanup:", cleanupError); - // Try a more aggressive cleanup - try { - const aggressiveClean = mtags["unrealircd.org/json-log"] - .replace(/\\s/g, " ") // Replace all \s with spaces - .replace(/\\'/g, "'") // Replace \' with ' - .replace(/\\&/g, "&"); // Replace \& with & - - jsonLogData = JSON.parse(aggressiveClean); - console.log("Successfully parsed with aggressive cleanup"); - } catch (aggressiveError) { - console.error("Failed aggressive cleanup:", aggressiveError); - // As a last resort, try to extract what we can - try { - // Look for JSON-like structure and extract key parts - const jsonStr = mtags["unrealircd.org/json-log"]; - const extracted: Record = {}; - // Try to extract common fields manually - const timeMatch = jsonStr.match(/"timestamp":"([^"]+)"/); - if (timeMatch) extracted.timestamp = timeMatch[1]; - const levelMatch = jsonStr.match(/"level":"([^"]+)"/); - if (levelMatch) extracted.level = levelMatch[1]; - const msgMatch = jsonStr.match(/"msg":"([^"]+)"/); - if (msgMatch) { - // Clean the message - extracted.msg = msgMatch[1].replace(/\\s/g, " "); - } - if (Object.keys(extracted).length > 0) { - jsonLogData = extracted; - console.log("Extracted partial data:", extracted); - } - } catch (extractError) { - console.error("Failed to extract partial data:", extractError); - } - } - } - } - } - - // Route all server notices to the server notices channel - const targetChannelId = "server-notices"; - - const newMessage: Message = { - id: uuidv4(), - type: isJsonLog ? "notice" : "notice", // Keep as notice type - content: message, - timestamp: timestamp, - userId: response.sender, - channelId: targetChannelId, - serverId: server.id, - reactions: [], - replyMessage: null, - mentioned: [], - tags: mtags, - jsonLogData, // Add parsed JSON log data - }; - - // Mark this message ID as processed to prevent duplicates - if (mtags?.msgid) { - useStore.setState((state) => ({ - processedMessageIds: new Set([...state.processedMessageIds, mtags.msgid]), - })); - } - - useStore.getState().addMessage(newMessage); - - // Play notification sound if appropriate (but not for historical messages) - // Don't count unread/mentions for historical messages (batch tag indicates chathistory playback) - const isHistoricalMessage = mtags?.batch !== undefined; - - if (!isHistoricalMessage) { - const state = useStore.getState(); - const serverCurrentUser = ircClient.getCurrentUser(response.serverId); - if ( - shouldPlayNotificationSound( - newMessage, - serverCurrentUser, - state.globalSettings, - ) - ) { - playNotificationSound(state.globalSettings); - } - } -}); - -ircClient.on("USERNOTICE", (response) => { - const { mtags, message, timestamp } = response; - - // Check for duplicate messages based on msgid - if (mtags?.msgid) { - const currentState = useStore.getState(); - if (currentState.processedMessageIds.has(mtags.msgid)) { - console.log(`Skipping duplicate USERNOTICE with msgid: ${mtags.msgid}`); - return; - } - } - - // Check if sender is ignored - const globalSettings = useStore.getState().globalSettings; - if ( - isUserIgnored( - response.sender, - undefined, - undefined, - globalSettings.ignoreList, - ) - ) { - // User is ignored, skip processing this notice - return; - } - - // Find the server - const server = useStore - .getState() - .servers.find((s) => s.id === response.serverId); - - if (!server) return; - - // Check if this NOTICE is from the server itself (sender contains a ".") - // Server notices should go to Server Notices, user notices should create PM tabs - if (response.sender.includes(".")) { - console.log( - "[USERNOTICE] Server notice detected, routing to Server Notices:", - response.sender, - ); - - // Check if this is a JSON log notice - const isJsonLog = mtags?.["unrealircd.org/json-log"]; - let jsonLogData = null; - if (isJsonLog) { - try { - const jsonString = mtags["unrealircd.org/json-log"]; - // Log the raw JSON string for debugging (first 200 chars) - console.log( - "Raw JSON log data:", - jsonString.substring(0, 200) + (jsonString.length > 200 ? "..." : ""), - ); - jsonLogData = JSON.parse(jsonString); - } catch (error) { - console.error("Failed to parse JSON log:", error); - console.error("Raw JSON string was:", mtags["unrealircd.org/json-log"]); - // Try to clean up common issues - try { - const cleanedJson = mtags["unrealircd.org/json-log"] - // Replace all \s with spaces (UnrealIRCd uses \s as non-standard space escape) - .replace(/\\s/g, " ") - // Handle other potential escape issues - .replace(/\\'/g, "'") - .replace(/\\&/g, "&"); - - jsonLogData = JSON.parse(cleanedJson); - console.log("Successfully parsed after cleanup"); - } catch (cleanupError) { - console.error("Failed to parse even after cleanup:", cleanupError); - // Try a more aggressive cleanup - try { - const aggressiveClean = mtags["unrealircd.org/json-log"] - .replace(/\\s/g, " ") // Replace all \s with spaces - .replace(/\\'/g, "'") // Replace \' with ' - .replace(/\\&/g, "&"); // Replace \& with & - - jsonLogData = JSON.parse(aggressiveClean); - console.log("Successfully parsed with aggressive cleanup"); - } catch (aggressiveError) { - console.error("Failed aggressive cleanup:", aggressiveError); - // As a last resort, try to extract what we can - try { - // Look for JSON-like structure and extract key parts - const jsonStr = mtags["unrealircd.org/json-log"]; - const extracted: Record = {}; - // Try to extract common fields manually - const timeMatch = jsonStr.match(/"timestamp":"([^"]+)"/); - if (timeMatch) extracted.timestamp = timeMatch[1]; - const levelMatch = jsonStr.match(/"level":"([^"]+)"/); - if (levelMatch) extracted.level = levelMatch[1]; - const msgMatch = jsonStr.match(/"msg":"([^"]+)"/); - if (msgMatch) { - // Clean the message - extracted.msg = msgMatch[1].replace(/\\s/g, " "); - } - if (Object.keys(extracted).length > 0) { - jsonLogData = extracted; - console.log("Extracted partial data:", extracted); - } - } catch (extractError) { - console.error("Failed to extract partial data:", extractError); - } - } - } - } - } - - // Route server notices to the server notices channel - const targetChannelId = "server-notices"; - - const newMessage: Message = { - id: uuidv4(), - type: isJsonLog ? "notice" : "notice", // Keep as notice type - content: message, - timestamp: timestamp, - userId: response.sender, - channelId: targetChannelId, - serverId: server.id, - reactions: [], - replyMessage: null, - mentioned: [], - tags: mtags, - jsonLogData, // Add parsed JSON log data - }; - - // Mark this message ID as processed to prevent duplicates - if (mtags?.msgid) { - useStore.setState((state) => ({ - processedMessageIds: new Set([ - ...state.processedMessageIds, - mtags.msgid, - ]), - })); - } - - useStore.getState().addMessage(newMessage); - - // Play notification sound if appropriate (but not for historical messages) - // Don't count unread/mentions for historical messages (batch tag indicates chathistory playback) - const isHistoricalMessage = mtags?.batch !== undefined; - - if (!isHistoricalMessage) { - const state = useStore.getState(); - const serverCurrentUser = ircClient.getCurrentUser(response.serverId); - if ( - shouldPlayNotificationSound( - newMessage, - serverCurrentUser, - state.globalSettings, - ) - ) { - playNotificationSound(state.globalSettings); - } - } - - return; // Don't process as a user notice - } - - // This is a user notice - treat it like a PM - console.log( - "[USERNOTICE] User notice detected, creating PM tab:", - response.sender, - ); - - // Don't create private chats with ourselves - const currentUser = ircClient.getCurrentUser(response.serverId); - if (currentUser?.username === response.sender) { - return; - } - - // Find or create private chat - let privateChat = server.privateChats?.find( - (pc) => pc.username === response.sender, - ); - - if (!privateChat) { - // Auto-create private chat when receiving a notice - useStore.getState().openPrivateChat(server.id, response.sender); - // Get the newly created private chat - privateChat = useStore - .getState() - .servers.find((s) => s.id === server.id) - ?.privateChats?.find((pc) => pc.username === response.sender); - } - - if (privateChat) { - const newMessage: Message = { - id: uuidv4(), - msgid: mtags?.msgid, - content: message, - timestamp, - userId: response.sender, - channelId: privateChat.id, // Use private chat ID as channel ID - serverId: server.id, - type: "notice" as const, // Mark as notice type - reactions: [], - replyMessage: null, - mentioned: [], // PMs don't have mentions in the traditional sense - tags: mtags, - }; - - useStore.getState().addMessage(newMessage); - - // Update private chat's last activity and unread count - const isHistoricalMessage = mtags?.batch !== undefined; - - // Play notification sound if appropriate (but not for historical messages) - if (!isHistoricalMessage) { - const state = useStore.getState(); - const serverCurrentUser = ircClient.getCurrentUser(response.serverId); - if ( - shouldPlayNotificationSound( - newMessage, - serverCurrentUser, - state.globalSettings, - ) - ) { - playNotificationSound(state.globalSettings); - } - } - - useStore.setState((state) => { - const updatedServers = state.servers.map((s) => { - if (s.id === response.serverId) { - const updatedPrivateChats = - s.privateChats?.map((pc) => { - if (pc.id === privateChat.id) { - const isActive = - getCurrentSelection(state).selectedPrivateChatId === pc.id; - return { - ...pc, - lastActivity: new Date(), - unreadCount: - isActive || isHistoricalMessage ? 0 : pc.unreadCount + 1, - isMentioned: !isHistoricalMessage && true, // All PMs are considered mentions (except historical) - }; - } - return pc; - }) || []; - return { ...s, privateChats: updatedPrivateChats }; - } - return s; - }); - return { servers: updatedServers }; - }); - } -}); - -ircClient.on( - "JOIN", - ({ serverId, username, channelName, batchTag, account, realname }) => { - // If this event is part of a batch, store it for later processing - if (batchTag) { - const state = useStore.getState(); - const batch = state.activeBatches[serverId]?.[batchTag]; - if (batch) { - batch.events.push({ - type: "JOIN", - data: { serverId, username, channelName, account, realname }, - }); - return; - } - } - - useStore.setState((state) => { - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - const existingChannel = server.channels.find( - (channel) => - channel.name.toLowerCase() === channelName.toLowerCase(), - ); - - if (!existingChannel) { - const newChannel: Channel = { - id: uuidv4(), - name: channelName, - topic: "", - isPrivate: false, - serverId, - unreadCount: 0, - isMentioned: false, - messages: [], - users: [], - }; - - return { - ...server, - channels: [...server.channels, newChannel], - }; - } - - // If channel exists but with different case, update the name to match server's canonical case - if (existingChannel.name !== channelName) { - const updatedChannels = server.channels.map((channel) => - channel.name.toLowerCase() === channelName.toLowerCase() - ? { ...channel, name: channelName } - : channel, - ); - return { - ...server, - channels: updatedChannels, - }; - } - const updatedChannels = server.channels.map((channel) => { - if (channel.name.toLowerCase() === channelName.toLowerCase()) { - const userAlreadyExists = channel.users.some( - (user) => user.username === username, - ); - if (!userAlreadyExists) { - // Check if this user already exists in other channels and copy their metadata - let userMetadata = {}; - const existingUserInOtherChannels = server.channels - .filter( - (ch) => ch.name.toLowerCase() !== channelName.toLowerCase(), - ) - .flatMap((ch) => ch.users) - .find((user) => user.username === username); - - if (existingUserInOtherChannels) { - userMetadata = { ...existingUserInOtherChannels.metadata }; - } else { - // Check if this is the current user and copy their metadata - const ircCurrentUser = ircClient.getCurrentUser(serverId); - const isCurrentUser = ircCurrentUser?.username === username; - if (isCurrentUser && ircCurrentUser) { - userMetadata = { ...ircCurrentUser.metadata }; - } - } - - return { - ...channel, - users: [ - ...channel.users, - { - id: uuidv4(), // Again, give them a unique ID - username, - isOnline: true, - status: "", - account, // Store account from extended-join if available - realname, // Store realname from extended-join if available - metadata: userMetadata, - }, - ], - }; - } - } - return channel; - }); - - return { ...server, channels: updatedChannels }; - } - - return server; - }); - - // Request metadata for the joining user to get their current metadata - // This is needed for users who join after we're already in the channel - useStore.getState().metadataList(serverId, username); - - return { servers: updatedServers }; - }); - - // If we joined a channel that doesn't exist in the store yet, create it - const ourNick = ircClient.getNick(serverId); - if (username === ourNick) { - const currentState = useStore.getState(); - const serverData = currentState.servers.find((s) => s.id === serverId); - const channelData = serverData?.channels.find( - (c) => c.name === channelName, - ); - - if (!channelData) { - // Channel doesn't exist in store, create it (similar to joinChannel) - const newChannel = { - id: uuidv4(), - name: channelName, - topic: "", - isPrivate: false, - serverId, - unreadCount: 0, - isMentioned: false, - messages: [], - users: [], - isLoadingHistory: true, // Start in loading state - needsWhoRequest: true, // Need to request WHO after CHATHISTORY completes - chathistoryRequested: true, // Mark that we've requested CHATHISTORY - }; - - // Add channel to store - useStore.setState((state) => ({ - servers: state.servers.map((server) => { - if (server.id === serverId) { - return { - ...server, - channels: [...server.channels, newChannel], - }; - } - return server; - }), - })); - - // Request CHATHISTORY for the new channel if server supports it - if (serverData?.capabilities?.includes("draft/chathistory")) { - ircClient.sendRaw(serverId, `CHATHISTORY LATEST ${channelName} * 50`); - - // Trigger event to notify store that history loading started - ircClient.triggerEvent("CHATHISTORY_LOADING", { - serverId, - channelName, - isLoading: true, - }); - } else { - // Server doesn't support CHATHISTORY, mark as not requested and not loading - useStore.setState((state) => ({ - servers: state.servers.map((server) => { - if (server.id === serverId) { - return { - ...server, - channels: server.channels.map((channel) => { - if (channel.name === channelName) { - return { - ...channel, - chathistoryRequested: false, - isLoadingHistory: false, - needsWhoRequest: true, - }; - } - return channel; - }), - }; - } - return server; - }), - })); - } - } else if (!channelData.chathistoryRequested) { - // Channel exists but CHATHISTORY hasn't been requested yet, request it - useStore.setState((state) => ({ - servers: state.servers.map((server) => { - if (server.id === serverId) { - return { - ...server, - channels: server.channels.map((channel) => { - if (channel.name === channelName) { - return { - ...channel, - isLoadingHistory: true, - needsWhoRequest: true, - chathistoryRequested: true, - }; - } - return channel; - }), - }; - } - return server; - }), - })); - - // Request CHATHISTORY for the existing channel if server supports it - if (serverData?.capabilities?.includes("draft/chathistory")) { - ircClient.sendRaw(serverId, `CHATHISTORY LATEST ${channelName} * 50`); - - // Trigger event to notify store that history loading started - ircClient.triggerEvent("CHATHISTORY_LOADING", { - serverId, - channelName, - isLoading: true, - }); - } else { - // Server doesn't support CHATHISTORY, don't set loading state - useStore.setState((state) => ({ - servers: state.servers.map((server) => { - if (server.id === serverId) { - return { - ...server, - channels: server.channels.map((channel) => { - if (channel.name === channelName) { - return { - ...channel, - chathistoryRequested: false, - isLoadingHistory: false, - needsWhoRequest: true, - }; - } - return channel; - }), - }; - } - return server; - }), - })); - } - } - } - - // If we joined a channel, request channel information - if (username === ourNick) { - // Find the channel in the store - const currentState = useStore.getState(); - const serverData = currentState.servers.find((s) => s.id === serverId); - const channelData = serverData?.channels.find( - (c) => c.name === channelName, - ); - - if (channelData?.isLoadingHistory) { - // CHATHISTORY is still loading, defer WHO request until it completes - useStore.setState((state) => ({ - servers: state.servers.map((server) => { - if (server.id === serverId) { - return { - ...server, - channels: server.channels.map((channel) => { - if (channel.name === channelName) { - return { ...channel, needsWhoRequest: true }; - } - return channel; - }), - }; - } - return server; - }), - })); - } else { - // Request topic and user list with WHOX to get account information - ircClient.sendRaw(serverId, `TOPIC ${channelName}`); - // Use WHOX to get user info: c=channel, u=username, h=hostname, n=nickname, f=flags, a=account, r=realname, o=op level - ircClient.sendRaw(serverId, `WHO ${channelName} %cuhnfaro`); - - // Request channel metadata if server supports it - if (serverSupportsMetadata(serverId)) { - setTimeout(() => { - ircClient.metadataGet(serverId, channelName, [ - "avatar", - "display-name", - ]); - }, 100); - } - } - } - - // Add join message if settings allow - const state = useStore.getState(); - if ( - state.globalSettings.showEvents && - state.globalSettings.showJoinsParts - ) { - const server = state.servers.find((s) => s.id === serverId); - if (server) { - const channel = server.channels.find( - (c) => c.name.toLowerCase() === channelName.toLowerCase(), - ); - if (channel) { - const joinMessage: Message = { - id: uuidv4(), - type: "join", - content: `joined ${channelName}`, - timestamp: new Date(), - userId: username, - channelId: channel.id, - serverId: serverId, - reactions: [], - replyMessage: null, - mentioned: [], - }; - - const key = `${serverId}-${channel.id}`; - useStore.setState((state) => ({ - messages: { - ...state.messages, - [key]: [...(state.messages[key] || []), joinMessage], - }, - })); - } - } - } - }, -); - -// Handle user changing their nickname -ircClient.on("NICK", ({ serverId, oldNick, newNick }) => { - useStore.setState((state) => { - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - const updatedChannels = server.channels.map((channel) => { - const updatedUsers = channel.users.map((user) => { - if (user.username === oldNick) { - return { ...user, username: newNick }; // Update the username - } - return user; - }); - return { ...channel, users: updatedUsers }; - }); - return { ...server, channels: updatedChannels }; - } - return server; - }); - - // Update currentUser only if this nick change is for the currently selected server - // and it's our own nick that changed - let updatedCurrentUser = state.currentUser; - const isSelectedServer = state.ui.selectedServerId === serverId; - const serverCurrentUser = ircClient.getCurrentUser(serverId); - const isOurNick = - serverCurrentUser?.username === oldNick || - serverCurrentUser?.username === newNick; - - if ( - isSelectedServer && - isOurNick && - state.currentUser && - state.currentUser.username === oldNick - ) { - updatedCurrentUser = { ...state.currentUser, username: newNick }; - } - - return { - servers: updatedServers, - currentUser: updatedCurrentUser, - }; - }); - - // Add nick change messages to all channels where the user was present - const state = useStore.getState(); - const server = state.servers.find((s) => s.id === serverId); - if ( - server && - state.globalSettings.showEvents && - state.globalSettings.showNickChanges - ) { - // Check if this was our own nick change - const ourNick = ircClient.getNick(serverId); - const isOurNickChange = oldNick === ourNick || newNick === ourNick; - - // Add message to each channel where the user was present - server.channels.forEach((channel) => { - const userWasInChannel = channel.users.some( - (user) => user.username === newNick, - ); - if (userWasInChannel) { - const nickChangeMessage: Message = { - id: uuidv4(), - type: "nick", - content: isOurNickChange - ? `are now known as **${newNick}**` - : `is now known as **${newNick}**`, - timestamp: new Date(), - userId: oldNick, // Use the old nick as the user ID for nick changes - channelId: channel.id, - serverId: serverId, - reactions: [], - replyMessage: null, - mentioned: [], - }; - - const key = `${serverId}-${channel.id}`; - useStore.setState((state) => ({ - messages: { - ...state.messages, - [key]: [...(state.messages[key] || []), nickChangeMessage], - }, - })); - } - }); - - // Also add to private chat if we have one open with this user - const privateChat = server.privateChats?.find( - (pc) => pc.username === oldNick || pc.username === newNick, - ); - if (privateChat) { - // Update the private chat username - useStore.setState((state) => { - const updatedServers = state.servers.map((s) => { - if (s.id === serverId) { - const updatedPrivateChats = s.privateChats?.map((pc) => { - if (pc.username === oldNick) { - return { ...pc, username: newNick }; - } - return pc; - }); - return { ...s, privateChats: updatedPrivateChats }; - } - return s; - }); - return { servers: updatedServers }; - }); - - // Add nick change message to private chat - const nickChangeMessage: Message = { - id: uuidv4(), - type: "nick", - content: isOurNickChange - ? `are now known as **${newNick}**` - : `is now known as **${newNick}**`, - timestamp: new Date(), - userId: oldNick, - channelId: privateChat.id, - serverId: serverId, - reactions: [], - replyMessage: null, - mentioned: [], - }; - - const key = `${serverId}-${privateChat.id}`; - useStore.setState((state) => ({ - messages: { - ...state.messages, - [key]: [...(state.messages[key] || []), nickChangeMessage], - }, - })); - } - - // Note: IRC client already handles updating its internal nick storage - } -}); - -ircClient.on("QUIT", ({ serverId, username, reason, batchTag }) => { - // If this event is part of a batch, store it for later processing - if (batchTag) { - const state = useStore.getState(); - const batch = state.activeBatches[serverId]?.[batchTag]; - if (batch) { - batch.events.push({ - type: "QUIT", - data: { serverId, username, reason }, - }); - return; - } - } - - // Get the current state to check which channels the user was in before removing them - const state = useStore.getState(); - const server = state.servers.find((s) => s.id === serverId); - const channelsUserWasIn: string[] = []; - - if (server) { - server.channels.forEach((channel) => { - const userWasInChannel = channel.users.some( - (user) => user.username === username, - ); - if (userWasInChannel) { - channelsUserWasIn.push(channel.id); - } - }); - } - - useStore.setState((state) => { - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - const updatedChannels = server.channels.map((channel) => { - const updatedUsers = channel.users.filter( - (user) => user.username !== username, - ); - return { ...channel, users: updatedUsers }; - }); - - return { ...server, channels: updatedChannels }; - } - return server; - }); - - return { servers: updatedServers }; - }); - - // Add quit message if settings allow - if (state.globalSettings.showEvents && state.globalSettings.showQuits) { - if (server) { - // Add quit message to all channels where the user was present - server.channels.forEach((channel) => { - if (channelsUserWasIn.includes(channel.id)) { - const quitMessage: Message = { - id: uuidv4(), - type: "quit", - content: reason ? `quit (${reason})` : "quit", - timestamp: new Date(), - userId: username, - channelId: channel.id, - serverId: serverId, - reactions: [], - replyMessage: null, - mentioned: [], - }; - - const key = `${serverId}-${channel.id}`; - useStore.setState((state) => ({ - messages: { - ...state.messages, - [key]: [...(state.messages[key] || []), quitMessage], - }, - })); - } - }); - } - } - - // Remove typing notifications and clear timers for the user who quit from all channels - if (server) { - channelsUserWasIn.forEach((channelId) => { - const key = `${serverId}-${channelId}`; - useStore.setState((state) => { - const currentUsers = state.typingUsers[key] || []; - const currentTimers = state.typingTimers[key] || {}; - - // Clear timer if it exists - if (currentTimers[username]) { - clearTimeout(currentTimers[username]); - } - - const { [username]: removedTimer, ...remainingTimers } = currentTimers; - - return { - typingUsers: { - ...state.typingUsers, - [key]: currentUsers.filter((u) => u.username !== username), - }, - typingTimers: { - ...state.typingTimers, - [key]: remainingTimers, - }, - }; - }); - }); - } -}); - -ircClient.on("ready", async ({ serverId, serverName, nickname }) => { - // Restore metadata for this server - restoreServerMetadata(serverId); - - // Send saved metadata to the server (after 001 ready) - // Only if server supports metadata - if (serverSupportsMetadata(serverId)) { - // First, subscribe to metadata updates - const currentSubs = - useStore.getState().metadataSubscriptions[serverId] || []; - if (currentSubs.length === 0) { - const defaultKeys = [ - "url", - "website", - "status", - "location", - "avatar", - "color", - "display-name", - "bot", - ]; - useStore.getState().metadataSub(serverId, defaultKeys); - } else { - } - - // Fetch our own metadata from the server first - // This will update saved values with what the server has - await fetchAndMergeOwnMetadata(serverId); - - // Now send any metadata we have saved (updated values after merge) - const savedMetadata = loadSavedMetadata(); - const serverMetadata = savedMetadata[serverId]; - const ourNick = ircClient.getNick(serverId); - - if (serverMetadata && ourNick) { - const ourMetadata = serverMetadata[ourNick]; - if (ourMetadata) { - // Send our own metadata to the server - Object.entries(ourMetadata).forEach(([key, { value, visibility }]) => { - if (value !== undefined) { - useStore - .getState() - .metadataSet(serverId, "*", key, value, visibility); - } - }); - } - } - } else { - } - - useStore.setState((state) => { - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - return { ...server, name: serverName }; // Update the server name for display purposes - } - return server; - }); - - const ircCurrentUser = ircClient.getCurrentUser(serverId); - let updatedCurrentUser = state.currentUser; - - if (ircCurrentUser) { - // Get saved metadata for this user on this server - const savedMetadata = loadSavedMetadata(); - const serverMetadata = savedMetadata[serverId]; - const userMetadata = serverMetadata?.[ircCurrentUser.username] || {}; - - // Create current user with IRC data and any saved metadata - updatedCurrentUser = { - ...ircCurrentUser, - metadata: { - ...(state.currentUser?.metadata || {}), - ...userMetadata, - }, - }; - } - - return { - servers: updatedServers, - currentUser: updatedCurrentUser, - }; - }); - - const savedServers = loadSavedServers(); - const savedServer = savedServers.find((s) => s.id === serverId); - - if (savedServer) { - // Send OPER command if oper on connect is enabled - if ( - savedServer.operOnConnect && - savedServer.operUsername && - savedServer.operPassword - ) { - try { - const decodedPassword = atob(savedServer.operPassword); - useStore - .getState() - .sendRaw( - serverId, - `OPER ${savedServer.operUsername} ${decodedPassword}`, - ); - } catch (error) { - console.error("Failed to decode operator password:", error); - // Fall back to using the password as-is if decoding fails - useStore - .getState() - .sendRaw( - serverId, - `OPER ${savedServer.operUsername} ${savedServer.operPassword}`, - ); - } - } - - // Get the saved channel order for this server - const savedChannelOrder = useStore.getState().channelOrder[serverId]; - - // If we have a saved order, use it to determine join sequence - let channelsToJoin: string[] = savedServer.channels; - - if (savedChannelOrder && savedChannelOrder.length > 0) { - // Map channel IDs to channel names using the saved order - // Note: savedChannelOrder has IDs, but we need names for joining - // We'll join in the order from savedServer.channels which should already be ordered - channelsToJoin = savedServer.channels; - } - - for (const channelName of channelsToJoin) { - if (channelName) { - useStore.getState().joinChannel(serverId, channelName); - } - } - - // Update the UI state to reflect the first joined channel - useStore.setState((state) => ({ - ui: { - ...state.ui, - selectedServerId: serverId, - selectedChannelId: savedServer.channels[0] || null, - }, - })); - } else { - } - - // Restore pinned private chats for this server - const pinnedChats = loadPinnedPrivateChats(); - const serverPinnedChats = pinnedChats[serverId] || []; - - if (serverPinnedChats.length > 0) { - // Sort by order - const sortedPinnedChats = [...serverPinnedChats].sort( - (a, b) => a.order - b.order, - ); - - useStore.setState((state) => { - const server = state.servers.find((s) => s.id === serverId); - if (!server) return {}; - - // Create private chat objects for pinned users - const restoredPrivateChats: PrivateChat[] = sortedPinnedChats.map( - ({ username, order }) => ({ - id: uuidv4(), - username, - serverId, - unreadCount: 0, - isMentioned: false, - lastActivity: new Date(), - isPinned: true, - order, - isOnline: false, // Will be updated by MONITOR - isAway: false, - }), - ); - - const updatedServers = state.servers.map((s) => { - if (s.id === serverId) { - // Merge existing private chats with restored pinned chats, deduplicating by username - const existingChats = s.privateChats || []; - const mergedPrivateChats = [...existingChats]; - - for (const restoredChat of restoredPrivateChats) { - const existingIndex = mergedPrivateChats.findIndex( - (pc) => pc.username === restoredChat.username, - ); - if (existingIndex === -1) { - // Chat doesn't exist, add it - mergedPrivateChats.push(restoredChat); - } else { - // Chat exists, ensure it's marked as pinned with correct order - mergedPrivateChats[existingIndex] = { - ...mergedPrivateChats[existingIndex], - isPinned: true, - order: restoredChat.order, - }; - } - } - - return { - ...s, - privateChats: mergedPrivateChats, - }; - } - return s; - }); - - return { servers: updatedServers }; - }); - - // MONITOR all pinned users - const usernames = sortedPinnedChats.map((pc) => pc.username); - ircClient.monitorAdd(serverId, usernames); - - // Request chathistory for each pinned PM - setTimeout(() => { - for (const { username } of sortedPinnedChats) { - ircClient.sendRaw(serverId, `CHATHISTORY LATEST ${username} * 50`); - } - }, 50); - - // For each pinned user, check if we have their info from channels first - setTimeout(() => { - const state = useStore.getState(); - const server = state.servers.find((s) => s.id === serverId); - if (!server) return; - - for (const { username } of sortedPinnedChats) { - // Check if we already have user info from channels - let hasUserInfo = false; - for (const channel of server.channels) { - const user = channel.users.find( - (u) => u.username.toLowerCase() === username.toLowerCase(), - ); - if (user?.realname && user.account !== undefined) { - // We have complete user info, copy it to the PM - hasUserInfo = true; - useStore.setState((state) => ({ - servers: state.servers.map((s) => { - if (s.id === serverId) { - return { - ...s, - privateChats: s.privateChats?.map((pm) => { - if (pm.username === username) { - return { - ...pm, - realname: user.realname, - account: user.account, - isBot: user.isBot, - }; - } - return pm; - }), - }; - } - return s; - }), - })); - break; - } - } - - // Only request WHO if we don't have complete user info - if (!hasUserInfo) { - // Request WHO to get current status using WHOX to also get account - // Fields: u=username, h=hostname, n=nickname, f=flags, a=account, r=realname - ircClient.sendRaw(serverId, `WHO ${username} %cuhnfrao`); - } - } - }, 100); - - // Note: We don't request METADATA GET for individual users as some servers reject this. - // Instead, we rely on metadata from shared channels (if user is in a channel with us) - // or from localStorage if we previously got their metadata. - } -}); - -ircClient.on( - "EXTJWT", - ({ serverId, requestedTarget, serviceName, jwtToken }) => { - console.log("🔑 EXTJWT received:", { - serverId, - requestedTarget, - serviceName, - jwtToken: jwtToken ? "present" : "missing", - }); - useStore.setState((state) => { - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - return { ...server, jwtToken }; - } - return server; - }); - return { servers: updatedServers }; - }); - }, -); - -ircClient.on("PART", ({ serverId, username, channelName, reason }) => { - useStore.setState((state) => { - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - const updatedChannels = server.channels.map((channel) => { - if (channel.name.toLowerCase() === channelName.toLowerCase()) { - return { - ...channel, - users: channel.users.filter((user) => user.username !== username), // Remove the user - }; - } - return channel; - }); - return { ...server, channels: updatedChannels }; - } - return server; - }); - - return { servers: updatedServers }; - }); - - // Add part message if settings allow - const state = useStore.getState(); - if (state.globalSettings.showEvents && state.globalSettings.showJoinsParts) { - const server = state.servers.find((s) => s.id === serverId); - if (server) { - const channel = server.channels.find((c) => c.name === channelName); - if (channel) { - const partMessage: Message = { - id: uuidv4(), - type: "part", - content: reason - ? `left ${channelName} (${reason})` - : `left ${channelName}`, - timestamp: new Date(), - userId: username, - channelId: channel.id, - serverId: serverId, - reactions: [], - replyMessage: null, - mentioned: [], - }; - - const key = `${serverId}-${channel.id}`; - useStore.setState((state) => ({ - messages: { - ...state.messages, - [key]: [...(state.messages[key] || []), partMessage], - }, - })); - } - } - } - - // Remove typing notification and clear timer for the user who parted - const server = state.servers.find((s) => s.id === serverId); - if (server) { - const channel = server.channels.find((c) => c.name === channelName); - if (channel) { - const key = `${serverId}-${channel.id}`; - useStore.setState((state) => { - const currentUsers = state.typingUsers[key] || []; - const currentTimers = state.typingTimers[key] || {}; - - // Clear timer if it exists - if (currentTimers[username]) { - clearTimeout(currentTimers[username]); - } - - const { [username]: removedTimer, ...remainingTimers } = currentTimers; - - return { - typingUsers: { - ...state.typingUsers, - [key]: currentUsers.filter((u) => u.username !== username), - }, - typingTimers: { - ...state.typingTimers, - [key]: remainingTimers, - }, - }; - }); - } - } -}); - -ircClient.on("MODE", ({ serverId, sender, target, modestring, modeargs }) => { - // Handle user mode responses (channel modes are handled by the protocol handler) - if (!target.startsWith("#")) { - // This is a user mode change - useStore.setState((state) => { - // Check if this is the current user - const currentUser = state.currentUser; - if ( - currentUser && - currentUser.username.toLowerCase() === target.toLowerCase() - ) { - // Check if this is an IRC operator mode change - const isIrcOp = modestring.includes("o"); - // Update current user's modes and IRC operator status - return { - currentUser: { - ...currentUser, - modes: modestring, - isIrcOp: isIrcOp, - }, - }; - } - - // If no currentUser in store, check if this MODE is for the IRC current user - const ircCurrentUser = ircClient.getCurrentUser(serverId); - if ( - !currentUser && - ircCurrentUser && - ircCurrentUser.username.toLowerCase() === target.toLowerCase() - ) { - // Check if this is an IRC operator mode change - const isIrcOp = modestring.includes("o"); - // Set the current user with modes and IRC operator status - return { - currentUser: { - ...ircCurrentUser, - modes: modestring, - isIrcOp: isIrcOp, - }, - }; - } - - // Update user in server users list - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - const updatedUsers = server.users.map((user) => { - if (user.username.toLowerCase() === target.toLowerCase()) { - console.log( - "Updated user", - user.username, - "modes to", - modestring, - ); - return { - ...user, - modes: modestring, - }; - } - return user; - }); - return { ...server, users: updatedUsers }; - } - return server; - }); - - return { servers: updatedServers }; - }); - } -}); - -ircClient.on( - "RPL_CHANNELMODEIS", - ({ serverId, channelName, modestring, modeargs }) => { - useStore.setState((state) => { - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - const updatedChannels = server.channels.map((channel) => { - if (channel.name.toLowerCase() === channelName.toLowerCase()) { - return { - ...channel, - modes: modestring, - modeArgs: modeargs, - }; - } - return channel; - }); - return { ...server, channels: updatedChannels }; - } - return server; - }); - return { servers: updatedServers }; - }); - }, -); - -ircClient.on( - "RPL_BANLIST", - ({ serverId, channel, mask, setter, timestamp }) => { - console.log( - `RPL_BANLIST received: serverId=${serverId}, channel=${channel}, mask=${mask}, setter=${setter}, timestamp=${timestamp}`, - ); - useStore.setState((state) => { - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - const updatedChannels = server.channels.map((ch) => { - if (ch.name === channel) { - const bans = ch.bans || []; - // Add the ban if it doesn't already exist - if (!bans.some((ban) => ban.mask === mask)) { - bans.push({ mask, setter, timestamp }); - console.log(`Added ban to channel ${channel}:`, { - mask, - setter, - timestamp, - }); - } else { - console.log(`Ban already exists for channel ${channel}:`, mask); - } - return { ...ch, bans }; - } - return ch; - }); - return { ...server, channels: updatedChannels }; - } - return server; - }); - return { servers: updatedServers }; - }); - }, -); - -ircClient.on( - "RPL_INVITELIST", - ({ serverId, channel, mask, setter, timestamp }) => { - console.log( - `RPL_INVITELIST received: serverId=${serverId}, channel=${channel}, mask=${mask}, setter=${setter}, timestamp=${timestamp}`, - ); - useStore.setState((state) => { - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - const updatedChannels = server.channels.map((ch) => { - if (ch.name === channel) { - const invites = ch.invites || []; - // Add the invite if it doesn't already exist - if (!invites.some((invite) => invite.mask === mask)) { - invites.push({ mask, setter, timestamp }); - console.log(`Added invite to channel ${channel}:`, { - mask, - setter, - timestamp, - }); - } else { - console.log( - `Invite already exists for channel ${channel}:`, - mask, - ); - } - return { ...ch, invites }; - } - return ch; - }); - return { ...server, channels: updatedChannels }; - } - return server; - }); - return { servers: updatedServers }; - }); - }, -); - -ircClient.on( - "RPL_EXCEPTLIST", - ({ serverId, channel, mask, setter, timestamp }) => { - console.log( - `RPL_EXCEPTLIST received: serverId=${serverId}, channel=${channel}, mask=${mask}, setter=${setter}, timestamp=${timestamp}`, - ); - useStore.setState((state) => { - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - const updatedChannels = server.channels.map((ch) => { - if (ch.name === channel) { - const exceptions = ch.exceptions || []; - // Add the exception if it doesn't already exist - if (!exceptions.some((exception) => exception.mask === mask)) { - exceptions.push({ mask, setter, timestamp }); - console.log(`Added exception to channel ${channel}:`, { - mask, - setter, - timestamp, - }); - } else { - console.log( - `Exception already exists for channel ${channel}:`, - mask, - ); - } - return { ...ch, exceptions }; - } - return ch; - }); - return { ...server, channels: updatedChannels }; - } - return server; - }); - return { servers: updatedServers }; - }); - }, -); - -ircClient.on("RPL_ENDOFBANLIST", ({ serverId, channel }) => { - // Ban list loading is complete - could trigger UI updates if needed - console.log(`Ban list loaded for ${channel} on server ${serverId}`); -}); - -ircClient.on("RPL_ENDOFINVITELIST", ({ serverId, channel }) => { - // Invite list loading is complete - could trigger UI updates if needed - console.log(`Invite list loaded for ${channel} on server ${serverId}`); -}); - -ircClient.on("RPL_YOUREOPER", ({ serverId, message }) => { - // Show notification that user is now an IRC operator - useStore.getState().addGlobalNotification({ - type: "note", - command: "Oper", - code: "OPER", - message: "You are an IRC Operator", - serverId, - }); -}); - -ircClient.on("RPL_YOURHOST", ({ serverId, serverName, version }) => { - // Check if the server is running UnrealIRCd - const isUnrealIRCd = version.includes("UnrealIRCd"); - - // Update the server with the UnrealIRCd information - useStore.setState((state) => ({ - servers: state.servers.map((server) => - server.id === serverId ? { ...server, isUnrealIRCd } : server, - ), - })); -}); - -// Topic handlers -ircClient.on("TOPIC", ({ serverId, channelName, topic, sender }) => { - useStore.setState((state) => { - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - const updatedChannels = server.channels.map((channel) => { - if (channel.name.toLowerCase() === channelName.toLowerCase()) { - return { ...channel, topic }; - } - return channel; - }); - return { ...server, channels: updatedChannels }; - } - return server; - }); - return { servers: updatedServers }; - }); - - // Optionally add a system message showing the topic change - const server = useStore.getState().servers.find((s) => s.id === serverId); - const channel = server?.channels.find((c) => c.name === channelName); - if (channel) { - const topicMessage: Message = { - id: `topic-${Date.now()}`, - channelId: channel.id, - userId: sender, - content: `changed the topic to: ${topic}`, - timestamp: new Date(), - serverId: serverId, - reactions: [], - type: "system", - replyMessage: null, - mentioned: [], - }; - - const key = `${serverId}-${channel.id}`; - useStore.setState((state) => ({ - messages: { - ...state.messages, - [key]: [...(state.messages[key] || []), topicMessage], - }, - })); - } -}); - -ircClient.on("RPL_TOPIC", ({ serverId, channelName, topic }) => { - useStore.setState((state) => { - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - const updatedChannels = server.channels.map((channel) => { - if (channel.name.toLowerCase() === channelName.toLowerCase()) { - return { ...channel, topic }; - } - return channel; - }); - return { ...server, channels: updatedChannels }; - } - return server; - }); - return { servers: updatedServers }; - }); -}); - -ircClient.on( - "RPL_TOPICWHOTIME", - ({ serverId, channelName, setter, timestamp }) => { - // This provides metadata about who set the topic and when - // We could store this if we extend the Channel interface - console.log( - `Topic for ${channelName} was set by ${setter} at ${new Date( - timestamp * 1000, - ).toISOString()}`, - ); - }, -); - -ircClient.on("RPL_NOTOPIC", ({ serverId, channelName }) => { - useStore.setState((state) => { - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - const updatedChannels = server.channels.map((channel) => { - if (channel.name.toLowerCase() === channelName.toLowerCase()) { - return { ...channel, topic: undefined }; - } - return channel; - }); - return { ...server, channels: updatedChannels }; - } - return server; - }); - return { servers: updatedServers }; - }); -}); - -// WHOIS event handlers -ircClient.on("WHOIS_USER", ({ serverId, nick, username, host, realname }) => { - useStore.setState((state) => { - const serverWhois = state.whoisData[serverId] || {}; - const existingData = serverWhois[nick] || { - nick, - specialMessages: [], - timestamp: Date.now(), - }; - - return { - whoisData: { - ...state.whoisData, - [serverId]: { - ...serverWhois, - [nick]: { - ...existingData, - username, - host, - realname, - timestamp: Date.now(), - }, - }, - }, - }; - }); -}); - -ircClient.on("WHOIS_SERVER", ({ serverId, nick, server, serverInfo }) => { - useStore.setState((state) => { - const serverWhois = state.whoisData[serverId] || {}; - const existingData = serverWhois[nick] || { - nick, - specialMessages: [], - timestamp: Date.now(), - }; - - return { - whoisData: { - ...state.whoisData, - [serverId]: { - ...serverWhois, - [nick]: { - ...existingData, - server, - serverInfo, - }, - }, - }, - }; - }); -}); - -ircClient.on("WHOIS_IDLE", ({ serverId, nick, idle, signon }) => { - useStore.setState((state) => { - const serverWhois = state.whoisData[serverId] || {}; - const existingData = serverWhois[nick] || { - nick, - specialMessages: [], - timestamp: Date.now(), - }; - - return { - whoisData: { - ...state.whoisData, - [serverId]: { - ...serverWhois, - [nick]: { - ...existingData, - idle, - signon, - }, - }, - }, - }; - }); -}); - -ircClient.on("WHOIS_CHANNELS", ({ serverId, nick, channels }) => { - useStore.setState((state) => { - const serverWhois = state.whoisData[serverId] || {}; - const existingData = serverWhois[nick] || { - nick, - specialMessages: [], - timestamp: Date.now(), - }; - - return { - whoisData: { - ...state.whoisData, - [serverId]: { - ...serverWhois, - [nick]: { - ...existingData, - channels, - }, - }, - }, - }; - }); -}); - -ircClient.on("WHOIS_ACCOUNT", ({ serverId, nick, account }) => { - useStore.setState((state) => { - const serverWhois = state.whoisData[serverId] || {}; - const existingData = serverWhois[nick] || { - nick, - specialMessages: [], - timestamp: Date.now(), - }; - - return { - whoisData: { - ...state.whoisData, - [serverId]: { - ...serverWhois, - [nick]: { - ...existingData, - account, - }, - }, - }, - }; - }); -}); - -ircClient.on("WHOIS_SECURE", ({ serverId, nick, message }) => { - useStore.setState((state) => { - const serverWhois = state.whoisData[serverId] || {}; - const existingData = serverWhois[nick] || { - nick, - specialMessages: [], - timestamp: Date.now(), - }; - - return { - whoisData: { - ...state.whoisData, - [serverId]: { - ...serverWhois, - [nick]: { - ...existingData, - secureConnection: message, - }, - }, - }, - }; - }); -}); - -ircClient.on("WHOIS_SPECIAL", ({ serverId, nick, message }) => { - useStore.setState((state) => { - const serverWhois = state.whoisData[serverId] || {}; - const existingData = serverWhois[nick] || { - nick, - specialMessages: [], - timestamp: Date.now(), - }; - - // Deduplicate special messages - const updatedMessages = existingData.specialMessages.includes(message) - ? existingData.specialMessages - : [...existingData.specialMessages, message]; - - return { - whoisData: { - ...state.whoisData, - [serverId]: { - ...serverWhois, - [nick]: { - ...existingData, - specialMessages: updatedMessages, - }, - }, - }, - }; - }); -}); - -ircClient.on("WHOIS_END", ({ serverId, nick }) => { - // Mark the whois data as complete - console.log(`WHOIS completed for ${nick} on server ${serverId}`); - - useStore.setState((state) => { - const serverWhois = state.whoisData[serverId] || {}; - const existingData = serverWhois[nick]; - - if (existingData) { - return { - whoisData: { - ...state.whoisData, - [serverId]: { - ...serverWhois, - [nick]: { - ...existingData, - isComplete: true, - }, - }, - }, - }; - } - - return state; - }); -}); - -ircClient.on("KICK", ({ serverId, username, target, channelName, reason }) => { - useStore.setState((state) => { - const updatedServers = state.servers.map((server) => { - const updatedChannels = server.channels.map((channel) => { - if (channel.name.toLowerCase() === channelName.toLowerCase()) { - return { - ...channel, - users: channel.users.filter((user) => user.username !== target), // Remove the user - }; - } - return channel; - }); - return { ...server, channels: updatedChannels }; - }); - - return { servers: updatedServers }; - }); - - // Add kick message if settings allow - const state = useStore.getState(); - if (state.globalSettings.showEvents && state.globalSettings.showKicks) { - const server = state.servers.find((s) => s.id === serverId); - if (server) { - const channel = server.channels.find((c) => c.name === channelName); - if (channel) { - const kickMessage: Message = { - id: uuidv4(), - type: "kick", - content: reason - ? `was kicked from ${channelName} by ${username} (${reason})` - : `was kicked from ${channelName} by ${username}`, - timestamp: new Date(), - userId: target, - channelId: channel.id, - serverId: serverId, - reactions: [], - replyMessage: null, - mentioned: [], - }; - - const key = `${serverId}-${channel.id}`; - useStore.setState((state) => ({ - messages: { - ...state.messages, - [key]: [...(state.messages[key] || []), kickMessage], - }, - })); - } - } - } - - // Remove typing notification and clear timer for the kicked user - const server = state.servers.find((s) => s.id === serverId); - if (server) { - const channel = server.channels.find((c) => c.name === channelName); - if (channel) { - const key = `${serverId}-${channel.id}`; - useStore.setState((state) => { - const currentUsers = state.typingUsers[key] || []; - const currentTimers = state.typingTimers[key] || {}; - - // Clear timer if it exists - if (currentTimers[target]) { - clearTimeout(currentTimers[target]); - } - - const { [target]: removedTimer, ...remainingTimers } = currentTimers; - - return { - typingUsers: { - ...state.typingUsers, - [key]: currentUsers.filter((u) => u.username !== target), - }, - typingTimers: { - ...state.typingTimers, - [key]: remainingTimers, - }, - }; - }); - } - } -}); - -ircClient.on("INVITE", ({ serverId, inviter, target, channel }) => { - const state = useStore.getState(); - const server = state.servers.find((s) => s.id === serverId); - if (!server) return; - - // Get current user's nickname to determine the active channel - const currentUser = ircClient.getCurrentUser(serverId); - if (!currentUser) return; - - // Determine where to show the invite message - // Show in the currently selected channel/chat, or fallback to server's first channel - let targetChannelId: string | null = null; - let targetChannelName: string | null = null; - - // If we're on this server and have a selected channel, use that - if (state.ui.selectedServerId === serverId) { - const currentSelection = getCurrentSelection(state); - if (currentSelection.selectedChannelId) { - const selectedChannel = server.channels.find( - (c) => c.id === currentSelection.selectedChannelId, - ); - if (selectedChannel) { - targetChannelId = selectedChannel.id; - targetChannelName = selectedChannel.name; - } - } else if (currentSelection.selectedPrivateChatId) { - // For private chats, we'll show it there - targetChannelId = currentSelection.selectedPrivateChatId; - } - } - - // If no active channel, use the first channel on the server as fallback - if (!targetChannelId && server.channels.length > 0) { - targetChannelId = server.channels[0].id; - targetChannelName = server.channels[0].name; - } - - if (!targetChannelId) return; - - // Create the invite message - const isForCurrentUser = - target.toLowerCase() === currentUser.username.toLowerCase(); - const content = isForCurrentUser - ? `${inviter} has invited you to join ${channel}` - : `${inviter} has invited ${target} to join ${channel}`; - - const inviteMessage: Message = { - id: uuidv4(), - type: "invite", - content, - timestamp: new Date(), - userId: inviter, - channelId: targetChannelId, - serverId: serverId, - reactions: [], - replyMessage: null, - mentioned: [], - inviteChannel: channel, - inviteTarget: target, - }; - - const key = `${serverId}-${targetChannelId}`; - useStore.setState((state) => ({ - messages: { - ...state.messages, - [key]: [...(state.messages[key] || []), inviteMessage], - }, - })); -}); - -ircClient.on("CAP_ACKNOWLEDGED", ({ serverId, key, capabilities }) => { - console.log( - `[CAP_ACKNOWLEDGED] Server ${serverId} acknowledged capability: ${key} (${capabilities})`, - ); - if (capabilities?.startsWith("draft/metadata")) { - // Check if already subscribed to avoid duplicate subscriptions - const currentSubs = - useStore.getState().metadataSubscriptions[serverId] || []; - console.log( - `[CAP_ACKNOWLEDGED] Current metadata subscriptions for server ${serverId}:`, - currentSubs, - ); - if (currentSubs.length === 0) { - // Subscribe to common metadata keys - const defaultKeys = [ - "url", - "website", - "status", - "location", - "avatar", - "color", - "display-name", - "bot", // Subscribe to bot metadata for tooltip information - ]; - console.log( - "[CAP_ACKNOWLEDGED] Attempting to subscribe to default metadata keys:", - defaultKeys, - ); - useStore.getState().metadataSub(serverId, defaultKeys); - } - - // Note: Metadata restoration/sending is now handled in the "ready" event - // to ensure the server is ready to receive METADATA commands - } - if (key === "sasl") { - const servers = loadSavedServers(); - for (const serv of servers) { - if (serv.id !== serverId) continue; - - if (!serv.saslEnabled) return; - } - ircClient.sendRaw(serverId, "AUTHENTICATE PLAIN"); - } -}); - -ircClient.on("AUTHENTICATE", ({ serverId, param }) => { - if (param !== "+") return; - - // Don't respond to AUTHENTICATE if CAP negotiation is already complete - if (ircClient.isCapNegotiationComplete(serverId)) return; - - let user: string | undefined; - let pass: string | undefined; - const servers = loadSavedServers(); - for (const serv of servers) { - if (serv.id !== serverId) continue; - - if (!serv.saslEnabled) return; - - user = serv.saslAccountName?.length ? serv.saslAccountName : serv.nickname; - pass = serv.saslPassword ? atob(serv.saslPassword) : undefined; - } - if (!user || !pass) - // wtf happened lol - return; - - ircClient.sendRaw( - serverId, - `AUTHENTICATE ${btoa(`${user}\x00${user}\x00${pass}`)}`, - ); - // Note: CAP END will be sent by the IRC client when SASL authentication completes (903/904-907 responses) - // ircClient.sendRaw(serverId, "CAP END"); - // ircClient.userOnConnect(serverId); -}); - -// Handle CAP LS to get informational capabilities like unrealircd.org/link-security -ircClient.on("CAP LS", ({ serverId, cliCaps }) => { - // Parse link-security from CAP LS (informational capability) - if (cliCaps.includes("unrealircd.org/link-security=")) { - const match = cliCaps.match(/unrealircd\.org\/link-security=(\d+)/); - if (match) { - const linkSecurityValue = Number.parseInt(match[1], 10) || 0; - - // Update server with link security value - useStore.setState((state) => { - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - return { - ...server, - linkSecurity: linkSecurityValue, - }; - } - return server; - }); - - return { servers: updatedServers }; - }); - - // Check for insecure connection and show warning modal - const currentState = useStore.getState(); - const currentServer = currentState.servers.find((s) => s.id === serverId); - const isLocalhost = - currentServer && - (currentServer.host === "localhost" || - currentServer.host === "127.0.0.1"); - const hasLowLinkSecurity = linkSecurityValue < 2; - - // Check if we should show warning based on individual skip preferences - const savedServers = loadSavedServers(); - const serverConfig = currentServer - ? savedServers.find( - (s) => - s.host === currentServer.host && s.port === currentServer.port, - ) - : undefined; - - const shouldWarnLocalhost = - isLocalhost && !serverConfig?.skipLocalhostWarning; - const shouldWarnLinkSecurity = - hasLowLinkSecurity && !serverConfig?.skipLinkSecurityWarning; - - if (shouldWarnLocalhost || shouldWarnLinkSecurity) { - useStore.setState((state) => { - // Check if warning already exists for this server - const existingWarning = state.ui.linkSecurityWarnings.find( - (w) => w.serverId === serverId, - ); - if (existingWarning) { - return state; // Don't add duplicate warning - } - - return { - ui: { - ...state.ui, - linkSecurityWarnings: [ - ...state.ui.linkSecurityWarnings, - { serverId, timestamp: Date.now() }, - ], - }, - }; - }); - } - } - } -}); - -ircClient.on("CAP ACK", ({ serverId, cliCaps }) => { - const caps = cliCaps.split(" "); - - for (const cap of caps) { - const tok = cap.split("="); - const capName = tok[0]; - const capValue = tok[1]; - - ircClient.capAck(serverId, capName, capValue ?? null); - } - - // Update server capabilities in store - useStore.setState((state) => { - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - return { - ...server, - capabilities: cliCaps.split(" "), - }; - } - return server; - }); - return { servers: updatedServers }; - }); - - // Check if we should prevent CAP END (for SASL, account registration, or link security warning) - const state = useStore.getState(); - const server = state.servers.find((s) => s.id === serverId); - let preventCapEnd = false; - - // Check if SASL was requested and acknowledged, AND we have credentials - if (caps.some((cap) => cap.startsWith("sasl"))) { - // Only prevent CAP END if we actually have SASL credentials - const servers = loadSavedServers(); - const savedServer = servers.find((s) => s.id === serverId); - if ( - savedServer?.saslEnabled && - savedServer?.saslAccountName && - savedServer?.saslPassword - ) { - preventCapEnd = true; - } - } - - // Check if there's pending account registration - const pendingReg = state.pendingRegistration; - if (pendingReg && pendingReg.serverId === serverId) { - preventCapEnd = true; - // Check if server supports account registration - if (server?.capabilities?.includes("draft/account-registration")) { - useStore - .getState() - .registerAccount( - serverId, - pendingReg.account, - pendingReg.email, - pendingReg.password, - ); - // Clear the pending registration - useStore.setState({ pendingRegistration: null }); - } else { - // Clear the pending registration - useStore.setState({ pendingRegistration: null }); - // Send CAP END since registration is not possible - preventCapEnd = false; - } - } - - // Check if link security warning modal is showing - prevent CAP END until user responds - if (state.ui.linkSecurityWarnings.some((w) => w.serverId === serverId)) { - preventCapEnd = true; - } - - if (!preventCapEnd) { - ircClient.sendRaw(serverId, "CAP END"); - ircClient.userOnConnect(serverId); - } else { - } -}); - -ircClient.on("LIST_CHANNEL", ({ serverId, channel, userCount, topic }) => { - useStore.setState((state) => { - if (!state.listingInProgress[serverId]) { - // Not currently listing, ignore - return {}; - } - const currentBuffer = state.channelListBuffer[serverId] || []; - const updatedBuffer = [...currentBuffer, { channel, userCount, topic }]; - return { - channelListBuffer: { - ...state.channelListBuffer, - [serverId]: updatedBuffer, - }, - }; - }); -}); - -ircClient.on("LIST_END", ({ serverId }) => { - // Move buffered channels to the main list and set listing as complete - useStore.setState((state) => ({ - channelList: { - ...state.channelList, - [serverId]: state.channelListBuffer[serverId] || [], - }, - channelListBuffer: { - ...state.channelListBuffer, - [serverId]: [], - }, - listingInProgress: { - ...state.listingInProgress, - [serverId]: false, - }, - })); -}); - -// CTCPs lol -ircClient.on("CHANMSG", (response) => { - const { channelName, message, timestamp } = response; - - // Find the server and channel - const server = useStore - .getState() - .servers.find((s) => s.id === response.serverId); - - if (!server) return; - - const parv = message.split(" "); - if (parv[0] === "\u0001VERSION\u0001") { - ircClient.sendRaw( - server.id, - `NOTICE ${response.sender} :\u0001VERSION ObsidianIRC v${ircClient.version}\u0001`, - ); - } - if (parv[0] === "\u0001PING") { - ircClient.sendRaw( - server.id, - `NOTICE ${response.sender} :\u0001PING ${parv[1]}\u0001`, - ); - } - if (parv[0] === "\u0001TIME\u0001") { - const date = new Date(); - ircClient.sendRaw( - server.id, - `NOTICE ${response.sender} :\u0001TIME ${date.toUTCString()}\u0001`, - ); - } -}); - -// TAGMSG typing -ircClient.on("TAGMSG", (response) => { - const { sender, mtags, channelName } = response; - - // Check if the sender is not the current user for this specific server - // we don't care about showing our own typing status - const currentUser = ircClient.getCurrentUser(response.serverId); - if (sender !== currentUser?.username && mtags && mtags["+typing"]) { - const isActive = mtags["+typing"] === "active"; - const server = useStore - .getState() - .servers.find((s) => s.id === response.serverId); - - if (!server) return; - - let key: string; - let user: User; - - const isChannel = channelName.startsWith("#"); - if (isChannel) { - const channel = server.channels.find((c) => c.name === channelName); - if (!channel) return; - - const foundUser = channel.users.find( - (u) => u.username === response.sender, - ); - if (!foundUser) return; - user = foundUser; - - key = `${server.id}-${channel.id}`; - } else { - // Private chat - const privateChat = server.privateChats?.find( - (pc) => pc.username === sender, - ); - if (!privateChat) return; - - // For private chats, create a user object - user = { - id: `${server.id}-${sender}`, - username: sender, - isOnline: true, - }; - - key = `${server.id}-${privateChat.id}`; - } - - useStore.setState((state) => { - const currentUsers = state.typingUsers[key] || []; - const currentTimers = state.typingTimers[key] || {}; - - if (isActive) { - // Clear existing timer for this user if it exists - if (currentTimers[user.username]) { - clearTimeout(currentTimers[user.username]); - } - - // Create a new timer to auto-clear typing notification after 6 seconds - const timer = setTimeout(() => { - useStore.setState((state) => { - const currentUsers = state.typingUsers[key] || []; - const currentTimers = state.typingTimers[key] || {}; - - // Remove the timer reference - const { [user.username]: removedTimer, ...remainingTimers } = - currentTimers; - - return { - typingUsers: { - ...state.typingUsers, - [key]: currentUsers.filter((u) => u.username !== user.username), - }, - typingTimers: { - ...state.typingTimers, - [key]: remainingTimers, - }, - }; - }); - }, 6000); - - // Don't add if already in the list - if (currentUsers.some((u) => u.username === user.username)) { - // Update timer even if user is already in list - return { - typingTimers: { - ...state.typingTimers, - [key]: { ...currentTimers, [user.username]: timer }, - }, - }; - } - - return { - typingUsers: { - ...state.typingUsers, - [key]: [...currentUsers, user], - }, - typingTimers: { - ...state.typingTimers, - [key]: { ...currentTimers, [user.username]: timer }, - }, - }; - } - // Remove the user from the list when they send "paused" or "done" - // Clear their timer if it exists - if (currentTimers[user.username]) { - clearTimeout(currentTimers[user.username]); - } - - const { [user.username]: removedTimer, ...remainingTimers } = - currentTimers; - - return { - typingUsers: { - ...state.typingUsers, - [key]: currentUsers.filter((u) => u.username !== user.username), - }, - typingTimers: { - ...state.typingTimers, - [key]: remainingTimers, - }, - }; - }); - } - - // Handle reactions - if (mtags?.["+draft/react"] && mtags["+draft/reply"]) { - const emoji = mtags["+draft/react"]; - const replyMessageId = mtags["+draft/reply"]; - - // Skip processing our own reactions since we handle them optimistically - const currentUser = ircClient.getCurrentUser(response.serverId); - if (sender === currentUser?.username) return; - - const server = useStore - .getState() - .servers.find((s) => s.id === response.serverId); - if (!server) return; - - let channel: Channel | PrivateChat | undefined; - const isChannel = channelName.startsWith("#"); - if (isChannel) { - channel = server.channels.find((c) => c.name === channelName); - } else { - // Private chat - channel = server.privateChats?.find((pc) => pc.username === channelName); - } - - if (!channel) return; - - // Find the message to add reaction to - const messages = getChannelMessages(server.id, channel.id); - const messageIndex = messages.findIndex((m) => m.msgid === replyMessageId); - if (messageIndex === -1) return; - - const message = messages[messageIndex]; - const existingReactionIndex = message.reactions.findIndex( - (r) => r.emoji === emoji && r.userId === sender, - ); - - useStore.setState((state) => { - const updatedMessages = [...messages]; - if (existingReactionIndex === -1) { - // Add new reaction - updatedMessages[messageIndex] = { - ...message, - reactions: [...message.reactions, { emoji, userId: sender }], - }; - } else { - // Remove existing reaction (toggle behavior) - updatedMessages[messageIndex] = { - ...message, - reactions: message.reactions.filter( - (_, i) => i !== existingReactionIndex, - ), - }; - } - - const key = `${server.id}-${channel.id}`; - return { - messages: { - ...state.messages, - [key]: updatedMessages, - }, - }; - }); - } - - // Handle unreacts - if (mtags?.["+draft/unreact"] && mtags["+draft/reply"]) { - const emoji = mtags["+draft/unreact"]; - const replyMessageId = mtags["+draft/reply"]; - - // Skip processing our own unreacts since we handle them optimistically - const currentUser = ircClient.getCurrentUser(response.serverId); - if (sender === currentUser?.username) return; - - const server = useStore - .getState() - .servers.find((s) => s.id === response.serverId); - if (!server) return; - - let channel: Channel | PrivateChat | undefined; - const isChannel = channelName.startsWith("#"); - if (isChannel) { - channel = server.channels.find((c) => c.name === channelName); - } else { - // Private chat - channel = server.privateChats?.find((pc) => pc.username === channelName); - } - - if (!channel) return; - - // Find the message to remove reaction from - const messages = getChannelMessages(server.id, channel.id); - const messageIndex = messages.findIndex((m) => m.msgid === replyMessageId); - if (messageIndex === -1) return; - - const message = messages[messageIndex]; - const existingReactionIndex = message.reactions.findIndex( - (r) => r.emoji === emoji && r.userId === sender, - ); - - // Only remove if the reaction exists - if (existingReactionIndex !== -1) { - useStore.setState((state) => { - const updatedMessages = [...messages]; - updatedMessages[messageIndex] = { - ...message, - reactions: message.reactions.filter( - (_, i) => i !== existingReactionIndex, - ), - }; - - const key = `${server.id}-${channel.id}`; - return { - messages: { - ...state.messages, - [key]: updatedMessages, - }, - }; - }); - } - } - - // Handle link previews - if ( - mtags && - (mtags["obsidianirc/link-preview-title"] || - mtags["obsidianirc/link-preview-snippet"] || - mtags["obsidianirc/link-preview-meta"]) && - mtags["+reply"] - ) { - const replyMessageId = mtags["+reply"]; - - const server = useStore - .getState() - .servers.find((s) => s.id === response.serverId); - if (!server) return; - - let channel: Channel | PrivateChat | undefined; - const isChannel = channelName.startsWith("#"); - if (isChannel) { - channel = server.channels.find((c) => c.name === channelName); - } else { - // Private chat - channel = server.privateChats?.find((pc) => pc.username === channelName); - } - - if (!channel) return; - - // Find the message to add link preview to - const messages = getChannelMessages(server.id, channel.id); - const messageIndex = messages.findIndex((m) => m.msgid === replyMessageId); - if (messageIndex === -1) return; - - const message = messages[messageIndex]; - - // Helper function to unescape IRC tag values - const unescapeTagValue = ( - value: string | undefined, - ): string | undefined => { - if (!value) return undefined; - // IRC tag escaping: \: = ; \s = space \\ = \ \r = CR \n = LF - return value - .replace(/\\s/g, " ") - .replace(/\\:/g, ";") - .replace(/\\r/g, "\r") - .replace(/\\n/g, "\n") - .replace(/\\\\/g, "\\"); - }; - - useStore.setState((state) => { - const updatedMessages = [...messages]; - updatedMessages[messageIndex] = { - ...message, - linkPreviewTitle: unescapeTagValue( - mtags["obsidianirc/link-preview-title"], - ), - linkPreviewSnippet: unescapeTagValue( - mtags["obsidianirc/link-preview-snippet"], - ), - linkPreviewMeta: unescapeTagValue( - mtags["obsidianirc/link-preview-meta"], - ), - }; - - const key = `${server.id}-${channel.id}`; - return { - messages: { - ...state.messages, - [key]: updatedMessages, - }, - }; - }); - } -}); - -ircClient.on("REDACT", ({ serverId, target, msgid, sender }) => { - useStore.setState((state) => { - const server = state.servers.find((s) => s.id === serverId); - if (!server) return {}; - - let channel: Channel | PrivateChat | undefined; - const isChannel = target.startsWith("#"); - if (isChannel) { - channel = server.channels.find((c) => c.name === target); - } else { - // Private chat - channel = server.privateChats?.find((pc) => pc.username === target); - } - - if (!channel) return {}; - - // Find and replace the message with a system message - const messages = getChannelMessages(server.id, channel.id); - const messageIndex = messages.findIndex((m) => m.msgid === msgid); - if (messageIndex === -1) return {}; - - const updatedMessages = [...messages]; - const originalMessage = updatedMessages[messageIndex]; - - // Determine if the sender deleted their own message - const isSender = originalMessage.userId === sender; - const deletionMessage = isSender - ? "This message has been deleted by the sender" - : "This message has been deleted by a member of staff"; - - // Replace the entire message with a system message - updatedMessages[messageIndex] = { - id: originalMessage.id, - msgid: originalMessage.msgid, - content: deletionMessage, - timestamp: originalMessage.timestamp, - userId: "system", - channelId: originalMessage.channelId, - serverId: originalMessage.serverId, - type: "system", - reactions: [], - replyMessage: null, - mentioned: [], - tags: originalMessage.tags, - }; - - const key = `${server.id}-${channel.id}`; - return { - messages: { - ...state.messages, - [key]: updatedMessages, - }, - }; - }); -}); - -// Nick error event handler -ircClient.on("NICK_ERROR", ({ serverId, code, error, nick, message }) => { - // Handle 433 (nickname already in use) with automatic retry - if (code === "433" && nick) { - const newNick = `${nick}_`; - - // Attempt to change to the nick with underscore appended - ircClient.changeNick(serverId, newNick); - - // Add a system message about the retry - const state = useStore.getState(); - const server = state.servers.find((s) => s.id === serverId); - if (server && getCurrentSelection(state).selectedChannelId) { - const channel = server.channels.find( - (c) => c.id === getCurrentSelection(state).selectedChannelId, - ); - if (channel) { - const retryMessage: Message = { - id: uuidv4(), - type: "system", - content: `Nickname '${nick}' already in use, retrying with '${newNick}'`, - timestamp: new Date(), - userId: "system", - channelId: channel.id, - serverId: serverId, - reactions: [], - replyMessage: null, - mentioned: [], - }; - - const key = `${serverId}-${channel.id}`; - useStore.setState((state) => ({ - messages: { - ...state.messages, - [key]: [...(state.messages[key] || []), retryMessage], - }, - })); - } - } - - // Don't show error notification for 433 since we're auto-retrying - return; - } - - // Add to global notifications for visibility (for other error codes) - const state = useStore.getState(); - state.addGlobalNotification({ - type: "fail", - command: "NICK", - code, - message: `${error}: ${message}`, - target: nick, - serverId, - }); - - // Also add a system message to the current channel - const server = state.servers.find((s) => s.id === serverId); - if (server && getCurrentSelection(state).selectedChannelId) { - const channel = server.channels.find( - (c) => c.id === getCurrentSelection(state).selectedChannelId, - ); - if (channel) { - const errorMessage: Message = { - id: uuidv4(), - type: "system", - content: `Nick change failed: ${error} ${nick ? `(${nick})` : ""}`, - timestamp: new Date(), - userId: "system", - channelId: channel.id, - serverId: serverId, - reactions: [], - replyMessage: null, - mentioned: [], - }; - - const key = `${serverId}-${channel.id}`; - useStore.setState((state) => ({ - messages: { - ...state.messages, - [key]: [...(state.messages[key] || []), errorMessage], - }, - })); - } - } -}); - -// Standard reply event handlers -ircClient.on("FAIL", ({ serverId, command, code, target, message }) => { - // Add to global notifications for visibility - const state = useStore.getState(); - state.addGlobalNotification({ - type: "fail", - command, - code, - message, - target, - serverId, - }); -}); - -ircClient.on("WARN", ({ serverId, command, code, target, message }) => { - const state = useStore.getState(); - const server = state.servers.find((s) => s.id === serverId); - if (server) { - // Try to add to the currently selected channel first, fallback to first channel - let channel = server.channels.find( - (c) => c.id === getCurrentSelection(state).selectedChannelId, - ); - if (!channel) { - channel = server.channels[0]; - } - if (channel) { - const notificationMessage: Message = { - id: uuidv4(), - type: "standard-reply", - content: `WARN ${command} ${code}${target ? ` ${target}` : ""}: ${message}`, - timestamp: new Date(), - userId: "system", - channelId: channel.id, - serverId: serverId, - reactions: [], - replyMessage: null, - mentioned: [], - standardReplyType: "WARN", - standardReplyCommand: command, - standardReplyCode: code, - standardReplyTarget: target, - standardReplyMessage: message, - }; - - const key = `${serverId}-${channel.id}`; - useStore.setState((state) => ({ - messages: { - ...state.messages, - [key]: [...(state.messages[key] || []), notificationMessage], - }, - })); - } - } -}); - -ircClient.on("NOTE", ({ serverId, command, code, target, message }) => { - const state = useStore.getState(); - const server = state.servers.find((s) => s.id === serverId); - if (server) { - // Try to add to the currently selected channel first, fallback to first channel - let channel = server.channels.find( - (c) => c.id === getCurrentSelection(state).selectedChannelId, - ); - if (!channel) { - channel = server.channels[0]; - } - if (channel) { - const notificationMessage: Message = { - id: uuidv4(), - type: "standard-reply", - content: `NOTE ${command} ${code}${target ? ` ${target}` : ""}: ${message}`, - timestamp: new Date(), - userId: "system", - channelId: channel.id, - serverId: serverId, - reactions: [], - replyMessage: null, - mentioned: [], - standardReplyType: "NOTE", - standardReplyCommand: command, - standardReplyCode: code, - standardReplyTarget: target, - standardReplyMessage: message, - }; - - const key = `${serverId}-${channel.id}`; - useStore.setState((state) => ({ - messages: { - ...state.messages, - [key]: [...(state.messages[key] || []), notificationMessage], - }, - })); - } - } -}); - -// Account registration event handlers -ircClient.on("REGISTER_SUCCESS", ({ serverId, account, message }) => { - const state = useStore.getState(); - const server = state.servers.find((s) => s.id === serverId); - if (server) { - const channel = server.channels[0]; - if (channel) { - const notificationMessage: Message = { - id: uuidv4(), - type: "system", - content: `Account registration successful for ${account}: ${message}`, - timestamp: new Date(), - userId: "system", - channelId: channel.id, - serverId: serverId, - reactions: [], - replyMessage: null, - mentioned: [], - }; - - const key = `${serverId}-${channel.id}`; - useStore.setState((state) => ({ - messages: { - ...state.messages, - [key]: [...(state.messages[key] || []), notificationMessage], - }, - })); - } - } -}); - -ircClient.on( - "REGISTER_VERIFICATION_REQUIRED", - ({ serverId, account, message }) => { - const state = useStore.getState(); - const server = state.servers.find((s) => s.id === serverId); - if (server) { - const channel = server.channels[0]; - if (channel) { - const notificationMessage: Message = { - id: uuidv4(), - type: "system", - content: `Account registration for ${account} requires verification: ${message}`, - timestamp: new Date(), - userId: "system", - channelId: channel.id, - serverId: serverId, - reactions: [], - replyMessage: null, - mentioned: [], - }; - - const key = `${serverId}-${channel.id}`; - useStore.setState((state) => ({ - messages: { - ...state.messages, - [key]: [...(state.messages[key] || []), notificationMessage], - }, - })); - } - } - }, -); - -ircClient.on("VERIFY_SUCCESS", ({ serverId, account, message }) => { - const state = useStore.getState(); - const server = state.servers.find((s) => s.id === serverId); - if (server) { - const channel = server.channels[0]; - if (channel) { - const notificationMessage: Message = { - id: uuidv4(), - type: "system", - content: `Account verification successful for ${account}: ${message}`, - timestamp: new Date(), - userId: "system", - channelId: channel.id, - serverId: serverId, - reactions: [], - replyMessage: null, - mentioned: [], - }; - - const key = `${serverId}-${channel.id}`; - useStore.setState((state) => ({ - messages: { - ...state.messages, - [key]: [...(state.messages[key] || []), notificationMessage], - }, - })); - } - } -}); - -// Metadata event handlers -ircClient.on("METADATA", ({ serverId, target, key, visibility, value }) => { - useStore.setState((state) => { - // Resolve the target - if it's "*", it refers to the current user - const serverCurrentUser = ircClient.getCurrentUser(serverId); - const resolvedTarget = - target === "*" - ? ircClient.getNick(serverId) || serverCurrentUser?.username || target - : target.split("!")[0]; // Extract nickname from mask - - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - // Update metadata for users in channels - const updatedChannels = server.channels.map((channel) => { - const updatedUsers = channel.users.map((user) => { - if (user.username === resolvedTarget) { - const metadata = { ...(user.metadata || {}) }; - if (value) { - metadata[key] = { value, visibility }; - } else { - delete metadata[key]; - } - console.log( - `[METADATA] Updated user ${resolvedTarget} in channel ${channel.name} with ${key}=${value}`, - ); - return { ...user, metadata }; - } - return user; - }); - - // Update metadata for the channel itself if target matches channel name - const channelMetadata = { ...(channel.metadata || {}) }; - if (resolvedTarget === channel.name) { - if (value) { - channelMetadata[key] = { value, visibility }; - } else { - delete channelMetadata[key]; - } - } - - return { - ...channel, - users: updatedUsers, - metadata: channelMetadata, - }; - }); - - // Update metadata for the server itself if target is server - const updatedMetadata = { ...(server.metadata || {}) }; - if (resolvedTarget === server.name) { - if (value) { - updatedMetadata[key] = { value, visibility }; - } else { - delete updatedMetadata[key]; - } - } - - // Update metadata for private chat users - const updatedPrivateChats = server.privateChats?.map((pm) => { - if (pm.username.toLowerCase() === resolvedTarget.toLowerCase()) { - // We don't store metadata directly on PrivateChat, - // but we can use this to trigger UI updates - // The avatar/metadata will be looked up from savedMetadata - return { ...pm }; - } - return pm; - }); - - return { - ...server, - channels: updatedChannels, - metadata: updatedMetadata, - privateChats: updatedPrivateChats, - }; - } - return server; - }); - - // Update current user metadata if the target matches any connected user - let updatedCurrentUser = state.currentUser; - const currentUserForServer = ircClient.getCurrentUser(serverId); - - // Check if this metadata is for the current user on this server - if ( - currentUserForServer && - currentUserForServer.username === resolvedTarget - ) { - // If this is the first time setting current user or it's for the selected server, update global state - if (!updatedCurrentUser || state.ui.selectedServerId === serverId) { - const metadata = { ...(currentUserForServer.metadata || {}) }; - if (value) { - metadata[key] = { value, visibility }; - } else { - delete metadata[key]; - } - // Preserve existing isIrcOp and modes when updating currentUser - updatedCurrentUser = { - ...currentUserForServer, - metadata, - isIrcOp: state.currentUser?.isIrcOp, - modes: state.currentUser?.modes, - }; - } - // If there's already a current user but it's for a different server, - // still update if this is the selected server or if there's no current user - else if ( - state.currentUser && - state.currentUser.username === resolvedTarget - ) { - const metadata = { ...(state.currentUser.metadata || {}) }; - if (value) { - metadata[key] = { value, visibility }; - } else { - delete metadata[key]; - } - // Preserve existing isIrcOp and modes when updating currentUser - updatedCurrentUser = { - ...state.currentUser, - metadata, - isIrcOp: state.currentUser.isIrcOp, - modes: state.currentUser.modes, - }; - } - } - - // Save metadata to localStorage - const savedMetadata = loadSavedMetadata(); - if (!savedMetadata[serverId]) { - savedMetadata[serverId] = {}; - } - if (!savedMetadata[serverId][resolvedTarget]) { - savedMetadata[serverId][resolvedTarget] = {}; - } - if (value) { - savedMetadata[serverId][resolvedTarget][key] = { value, visibility }; - } else { - delete savedMetadata[serverId][resolvedTarget][key]; - } - saveMetadataToLocalStorage(savedMetadata); - - // Update channel metadata cache if this is for a channel - if (resolvedTarget.startsWith("#")) { - const cache = state.channelMetadataCache[serverId] || {}; - const channelCache = cache[resolvedTarget] || { fetchedAt: Date.now() }; - - if (key === "avatar") { - channelCache.avatar = value || undefined; - } else if (key === "display-name") { - channelCache.displayName = value || undefined; - } - - channelCache.fetchedAt = Date.now(); - - const updatedCache = { - ...state.channelMetadataCache, - [serverId]: { - ...cache, - [resolvedTarget]: channelCache, - }, - }; - - // Remove from fetch queue - const queue = state.channelMetadataFetchQueue[serverId]; - if (queue) { - const newQueue = new Set(queue); - newQueue.delete(resolvedTarget); - - return { - servers: updatedServers, - currentUser: updatedCurrentUser, - channelMetadataCache: updatedCache, - channelMetadataFetchQueue: { - ...state.channelMetadataFetchQueue, - [serverId]: newQueue, - }, - }; - } - - return { - servers: updatedServers, - currentUser: updatedCurrentUser, - channelMetadataCache: updatedCache, - }; - } - - return { - servers: updatedServers, - currentUser: updatedCurrentUser, - metadataChangeCounter: state.metadataChangeCounter + 1, - }; - }); -}); - -ircClient.on( - "METADATA_KEYVALUE", - ({ serverId, target, key, visibility, value }) => { - const state = useStore.getState(); - const isFetchingOwn = state.metadataFetchInProgress[serverId]; - - // Handle individual key-value responses (similar to METADATA) - useStore.setState((state) => { - // Resolve the target - if it's "*", it refers to the current user - const resolvedTarget = - target === "*" - ? ircClient.getNick(serverId) || state.currentUser?.username || target - : target.split("!")[0]; // Extract nickname from mask - - // If we're fetching our own metadata, update saved values - if (isFetchingOwn && target === "*") { - const savedMetadata = loadSavedMetadata(); - if (!savedMetadata[serverId]) { - savedMetadata[serverId] = {}; - } - if (!savedMetadata[serverId][resolvedTarget]) { - savedMetadata[serverId][resolvedTarget] = {}; - } - // Only overwrite saved value with server value if server actually has a value - // Empty/null values from server mean "not set", so keep our local value - if (value !== null && value !== undefined && value !== "") { - savedMetadata[serverId][resolvedTarget][key] = { value, visibility }; - saveMetadataToLocalStorage(savedMetadata); - } - // If server has the key but no value, and we have a local value, we'll send ours later - } - - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - // Update metadata for users in channels - const updatedChannels = server.channels.map((channel) => { - const userInChannel = channel.users.find( - (u) => u.username === resolvedTarget, - ); - if (userInChannel) { - } - - const updatedUsers = channel.users.map((user) => { - if (user.username === resolvedTarget) { - const metadata = { ...(user.metadata || {}) }; - // Only update metadata if value is present (not empty/null) - if (value !== null && value !== undefined && value !== "") { - metadata[key] = { value, visibility }; - } else { - // If server sends empty/null, remove the key (it's not set on server) - // But only if we're not in fetch mode - during fetch, keep local values - if (!isFetchingOwn || target !== "*") { - delete metadata[key]; - } - } - return { ...user, metadata }; - } - return user; - }); - - // Update metadata for the channel itself if target matches channel name - let updatedChannelMetadata = channel.metadata || {}; - if (resolvedTarget === channel.name) { - // Only update THIS channel's metadata if the target matches exactly - updatedChannelMetadata = { ...updatedChannelMetadata }; - if (value !== null && value !== undefined && value !== "") { - updatedChannelMetadata[key] = { value, visibility }; - } else { - delete updatedChannelMetadata[key]; - } - } - - return { - ...channel, - users: updatedUsers, - metadata: updatedChannelMetadata, - }; - }); - - return { - ...server, - channels: updatedChannels, - }; - } - return server; - }); - - // Update current user metadata - let updatedCurrentUser = state.currentUser; - if (state.currentUser?.username === resolvedTarget) { - const metadata = { ...(state.currentUser.metadata || {}) }; - // Only update metadata if value is present (not empty/null) - if (value !== null && value !== undefined && value !== "") { - metadata[key] = { value, visibility }; - } else { - // If server sends empty/null, remove the key (it's not set on server) - // But only if we're not in fetch mode - during fetch, keep local values - if (!isFetchingOwn || target !== "*") { - delete metadata[key]; - } - } - updatedCurrentUser = { ...state.currentUser, metadata }; - console.log( - `[METADATA_KEYVALUE] Updated current user ${resolvedTarget} with ${key}=${value}`, - ); - } - - // Save metadata to localStorage (unless we're in fetch mode - already saved above) - if (!isFetchingOwn || target !== "*") { - const savedMetadata = loadSavedMetadata(); - if (!savedMetadata[serverId]) { - savedMetadata[serverId] = {}; - } - if (!savedMetadata[serverId][resolvedTarget]) { - savedMetadata[serverId][resolvedTarget] = {}; - } - savedMetadata[serverId][resolvedTarget][key] = { value, visibility }; - saveMetadataToLocalStorage(savedMetadata); - } - - // Update channel metadata cache if this is for a channel - if (resolvedTarget.startsWith("#")) { - const cache = state.channelMetadataCache[serverId] || {}; - const channelCache = cache[resolvedTarget] || { fetchedAt: Date.now() }; - - if (key === "avatar" && value) { - channelCache.avatar = value; - } else if (key === "display-name" && value) { - channelCache.displayName = value; - } - - channelCache.fetchedAt = Date.now(); - - const updatedCache = { - ...state.channelMetadataCache, - [serverId]: { - ...cache, - [resolvedTarget]: channelCache, - }, - }; - - // Remove from fetch queue - const queue = state.channelMetadataFetchQueue[serverId]; - if (queue) { - const newQueue = new Set(queue); - newQueue.delete(resolvedTarget); - - return { - servers: updatedServers, - currentUser: updatedCurrentUser, - channelMetadataCache: updatedCache, - channelMetadataFetchQueue: { - ...state.channelMetadataFetchQueue, - [serverId]: newQueue, - }, - metadataChangeCounter: state.metadataChangeCounter + 1, - }; - } - - return { - servers: updatedServers, - currentUser: updatedCurrentUser, - channelMetadataCache: updatedCache, - metadataChangeCounter: state.metadataChangeCounter + 1, - }; - } - - return { - servers: updatedServers, - currentUser: updatedCurrentUser, - metadataChangeCounter: state.metadataChangeCounter + 1, - }; - }); - }, -); - -ircClient.on("METADATA_KEYNOTSET", ({ serverId, target, key }) => { - const state = useStore.getState(); - const isFetchingOwn = state.metadataFetchInProgress[serverId]; - - // Resolve the target - if it's "*", it refers to the current user - const resolvedTarget = - target === "*" - ? ircClient.getNick(serverId) || state.currentUser?.username || target - : target.split("!")[0]; // Extract nickname from mask - - // If we're fetching our own metadata and the key is not set, delete it from saved values - if (isFetchingOwn && target === "*") { - const savedMetadata = loadSavedMetadata(); - if (savedMetadata[serverId]?.[resolvedTarget]?.[key]) { - delete savedMetadata[serverId][resolvedTarget][key]; - saveMetadataToLocalStorage(savedMetadata); - } - } - - // Handle key not set responses - useStore.setState((state) => { - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - // Remove metadata for users in channels - const updatedChannels = server.channels.map((channel) => { - const updatedUsers = channel.users.map((user) => { - if (user.username === resolvedTarget) { - const metadata = user.metadata || {}; - delete metadata[key]; - return { ...user, metadata }; - } - return user; - }); - - // Remove metadata for the channel itself if target matches channel name - const channelMetadata = channel.metadata || {}; - if ( - resolvedTarget === channel.name || - resolvedTarget.startsWith("#") - ) { - delete channelMetadata[key]; - } - - return { ...channel, users: updatedUsers, metadata: channelMetadata }; - }); - return { ...server, channels: updatedChannels }; - } - return server; - }); - - return { servers: updatedServers }; - }); -}); - -ircClient.on("METADATA_SUBOK", ({ serverId, keys }) => { - console.log( - `[METADATA_SUBOK] Successfully subscribed to keys for server ${serverId}:`, - keys, - ); - // Update subscriptions - useStore.setState((state) => { - const currentSubs = state.metadataSubscriptions[serverId] || []; - const newSubs = [...new Set([...currentSubs, ...keys])]; - return { - metadataSubscriptions: { - ...state.metadataSubscriptions, - [serverId]: newSubs, - }, - }; - }); -}); - -ircClient.on("METADATA_UNSUBOK", ({ serverId, keys }) => { - // Update subscriptions - useStore.setState((state) => { - const currentSubs = state.metadataSubscriptions[serverId] || []; - const newSubs = currentSubs.filter((k) => !keys.includes(k)); - return { - metadataSubscriptions: { - ...state.metadataSubscriptions, - [serverId]: newSubs, - }, - }; - }); -}); - -ircClient.on("METADATA_SUBS", ({ serverId, keys }) => { - // Set all subscriptions - useStore.setState((state) => ({ - metadataSubscriptions: { - ...state.metadataSubscriptions, - [serverId]: keys, - }, - })); -}); - -ircClient.on("BATCH_START", ({ serverId, batchId, type }) => { - // Start a batch - useStore.setState((state) => ({ - metadataBatches: { - ...state.metadataBatches, - [batchId]: { type, messages: [] }, - }, - })); -}); - -ircClient.on("BATCH_END", ({ serverId, batchId }) => { - // End a batch - process all messages in the batch - useStore.setState((state) => { - const batch = state.metadataBatches[batchId]; - if (batch) { - // Process batch messages (they should have been collected during the batch) - // For metadata batches, the individual METADATA_KEYVALUE events should have updated the state - } - const { [batchId]: _, ...remainingBatches } = state.metadataBatches; - return { - metadataBatches: remainingBatches, - }; - }); -}); - -// Helper function to process netsplit batches -function processBatchedNetsplit( - serverId: string, - batchId: string, - batch: BatchInfo, -) { - const store = useStore.getState(); - const batch_info = store.activeBatches[serverId]?.[batchId]; - if (!batch_info) return; - - const quitEvents = batch_info.events; - const [server1, server2] = batch_info.parameters || ["*.net", "*.split"]; - - // Create a single netsplit message - const netsplitMessage = { - id: `netsplit-${batchId}`, - content: "Oops! The net split! ⚠️", - timestamp: new Date(), - userId: "system", - channelId: "", // Will be set per channel - serverId, - type: "netsplit" as const, - batchId, - quitUsers: quitEvents.map((e) => e.data.username), - server1, - server2, - reactions: [], - replyMessage: null, - mentioned: [], - }; - - // Group affected channels and add the netsplit message to each - const affectedChannels = new Set(); - - // Process each quit event to remove users and track affected channels - quitEvents.forEach((event) => { - const { username } = event.data; - - // Find which channels this user was in and remove them - useStore.setState((state) => { - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - const updatedChannels = server.channels.map((channel) => { - const userIndex = channel.users.findIndex( - (u) => u.username === username, - ); - if (userIndex !== -1) { - affectedChannels.add(channel.id); - // Remove the user from the channel - const updatedUsers = channel.users.filter( - (u) => u.username !== username, - ); - return { ...channel, users: updatedUsers }; - } - return channel; - }); - return { ...server, channels: updatedChannels }; - } - return server; - }); - return { servers: updatedServers }; - }); - }); - - // Add netsplit message to each affected channel - affectedChannels.forEach((channelId) => { - const channelMessage = { ...netsplitMessage, channelId }; - useStore.getState().addMessage(channelMessage); - }); -} - -// Helper function to process netjoin batches -function processBatchedNetjoin( - serverId: string, - batchId: string, - batch: BatchInfo, -) { - const store = useStore.getState(); - const batch_info = store.activeBatches[serverId]?.[batchId]; - if (!batch_info) return; - - const joinEvents = batch_info.events; - const [server1, server2] = batch_info.parameters || ["*.net", "*.join"]; - - // Process each join event normally first - joinEvents.forEach((event) => { - // Re-trigger the JOIN event to add users back - if (event.type === "JOIN") { - ircClient.triggerEvent("JOIN", event.data); - } - }); - - // Find and update any existing netsplit messages to show rejoin - useStore.setState((state) => { - const updatedMessages = { ...state.messages }; - - Object.keys(updatedMessages).forEach((channelKey) => { - const messages = updatedMessages[channelKey]; - const updatedChannelMessages = messages.map((message) => { - if ( - message.type === "netsplit" && - message.serverId === serverId && - message.server1 === server1 && - message.server2 === server2 - ) { - // Update the netsplit message to show rejoin - return { - ...message, - content: "The network split and rejoined. ✅", - type: "netjoin" as const, - }; - } - return message; - }); - updatedMessages[channelKey] = updatedChannelMessages; - }); - - return { messages: updatedMessages }; - }); -} - -// Handle chathistory loading state -ircClient.on("CHATHISTORY_LOADING", ({ serverId, channelName, isLoading }) => { - useStore.setState((state) => { - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - const updatedChannels = server.channels.map((channel) => { - if (channel.name.toLowerCase() === channelName.toLowerCase()) { - const updatedChannel = { ...channel, isLoadingHistory: isLoading }; - - // If loading just completed and we need to send WHO, do it now - if (!isLoading && channel.needsWhoRequest) { - // Send WHO request now that CHATHISTORY is done - ircClient.sendRaw(serverId, `WHO ${channelName} %cuhnfaro`); - - // Request channel metadata if server supports it - if (serverSupportsMetadata(serverId)) { - ircClient.metadataGet(serverId, channelName, [ - "avatar", - "display-name", - ]); - } - - // Clear the flag - updatedChannel.needsWhoRequest = false; - } - - return updatedChannel; - } - return channel; - }); - return { ...server, channels: updatedChannels }; - } - return server; - }); - return { servers: updatedServers }; - }); -}); - -ircClient.on( - "METADATA_FAIL", - ({ serverId, subcommand, code, target, key, retryAfter }) => { - // Handle metadata failures - console.error(`Metadata ${subcommand} failed: ${code}`, { - target, - key, - retryAfter, - }); - // Could show user notifications here - }, -); - -// Load saved servers on store initialization - -// If default server is available, select it -if (__DEFAULT_IRC_SERVER__) { -} - -ircClient.on("RENAME", ({ serverId, oldName, newName, reason, user }) => { - useStore.setState((state) => { - const server = state.servers.find((s) => s.id === serverId); - if (!server) return {}; - - const channel = server.channels.find((c) => c.name === oldName); - if (!channel) return {}; - - channel.name = newName; - - const renameMessage: Message = { - id: `rename-${Date.now()}`, - content: `Channel has been renamed from ${oldName} to ${newName} by ${user}${reason ? ` (${reason})` : ""}`, - timestamp: new Date(), - userId: "system", - channelId: channel.id, - serverId, - type: "system", - reactions: [], - replyMessage: null, - mentioned: [], - }; - - const channelKey = `${serverId}-${channel.id}`; - const currentMessages = state.messages[channelKey] || []; - return { - messages: { - ...state.messages, - [channelKey]: [...currentMessages, renameMessage], - }, - }; - }); -}); - -ircClient.on("SETNAME", ({ serverId, user, realname }) => { - useStore.setState((state) => { - const server = state.servers.find((s) => s.id === serverId); - if (!server) return {}; - - // Update current user if it's us - if (user === state.currentUser?.username) { - return { - currentUser: { - ...state.currentUser, - realname: realname, - }, - }; - } - - // Update in channels - const updatedServers = state.servers.map((s) => { - if (s.id === serverId) { - const updatedChannels = s.channels.map((c) => ({ - ...c, - users: c.users.map((u) => - u.username === user ? { ...u, realname: realname } : u, - ), - })); - return { ...s, channels: updatedChannels }; - } - return s; - }); - - return { servers: updatedServers }; - }); -}); - -// MONITOR event handlers -ircClient.on("MONONLINE", ({ serverId, targets }) => { - useStore.setState((state) => { - const server = state.servers.find((s) => s.id === serverId); - if (!server) return {}; - - // Update private chats to mark users as online - const updatedServers = state.servers.map((s) => { - if (s.id === serverId) { - const updatedPrivateChats = s.privateChats?.map((pm) => { - const target = targets.find( - (t) => t.nick.toLowerCase() === pm.username.toLowerCase(), - ); - if (target) { - return { ...pm, isOnline: true, isAway: false }; - } - return pm; - }); - return { ...s, privateChats: updatedPrivateChats }; - } - return s; - }); - - return { servers: updatedServers }; - }); -}); - -ircClient.on("MONOFFLINE", ({ serverId, targets }) => { - useStore.setState((state) => { - const server = state.servers.find((s) => s.id === serverId); - if (!server) return {}; - - // Update private chats to mark users as offline - const updatedServers = state.servers.map((s) => { - if (s.id === serverId) { - const updatedPrivateChats = s.privateChats?.map((pm) => { - const isOffline = targets.some( - (t) => t.toLowerCase() === pm.username.toLowerCase(), - ); - if (isOffline) { - return { ...pm, isOnline: false, isAway: false }; - } - return pm; - }); - return { ...s, privateChats: updatedPrivateChats }; - } - return s; - }); - - return { servers: updatedServers }; - }); -}); - -// Handle AWAY notifications for monitored users (extended-monitor) -ircClient.on("AWAY", ({ serverId, username, awayMessage }) => { - useStore.setState((state) => { - const server = state.servers.find((s) => s.id === serverId); - if (!server) return {}; - - // Update private chats for monitored users - const updatedServers = state.servers.map((s) => { - if (s.id === serverId) { - const updatedPrivateChats = s.privateChats?.map((pm) => { - if (pm.username.toLowerCase() === username.toLowerCase()) { - return { - ...pm, - isAway: awayMessage !== undefined && awayMessage !== null, - awayMessage: awayMessage || undefined, - isOnline: true, // They're still online, just away - }; - } - return pm; - }); - return { ...s, privateChats: updatedPrivateChats }; - } - return s; - }); - - return { servers: updatedServers }; - }); -}); - -// Handle RPL_AWAY (301) from WHOIS responses -ircClient.on("RPL_AWAY", ({ serverId, nick, awayMessage }) => { - useStore.setState((state) => { - const updatedServers = state.servers.map((s) => { - if (s.id === serverId) { - const updatedPrivateChats = s.privateChats?.map((pm) => { - if (pm.username.toLowerCase() === nick.toLowerCase()) { - return { - ...pm, - awayMessage: awayMessage || undefined, - isAway: true, - }; - } - return pm; - }); - return { ...s, privateChats: updatedPrivateChats }; - } - return s; - }); - - return { servers: updatedServers }; - }); -}); - -// WHO reply handler - for standard WHO responses (352) when server doesn't support WHOX -ircClient.on( - "WHO_REPLY", - ({ - serverId, - channel, - username, - host, - server, - nick, - flags, - hopcount, - realname, - }) => { - const state = useStore.getState(); - const serverData = state.servers.find((s) => s.id === serverId); - if (!serverData) return; - - // Parse away status from flags (e.g., "H@" means here and operator, "G" means gone/away) - let isAway = false; - if (flags) { - // First character indicates here (H) or gone/away (G) - if (flags[0] === "G") { - isAway = true; - } else if (flags[0] === "H") { - isAway = false; - } - } - - // If channel is "*", this is a user-specific WHO query (e.g., "WHO username") - // Update private chats only in this case - if (channel === "*") { - useStore.setState((state) => { - const updatedServers = state.servers.map((s) => { - if (s.id === serverId) { - const updatedPrivateChats = s.privateChats?.map((pm) => { - if (pm.username.toLowerCase() === nick.toLowerCase()) { - // If user is away and this is a pinned PM, send WHOIS to get away message - if (isAway && pm.isPinned) { - setTimeout(() => { - ircClient.sendRaw(serverId, `WHOIS ${nick}`); - }, 100); - } - - return { - ...pm, - isOnline: true, - isAway: isAway, - }; - } - return pm; - }); - - return { - ...s, - privateChats: updatedPrivateChats, - }; - } - return s; - }); - - return { servers: updatedServers }; - }); - return; // Don't process channel user list for user-specific queries - } - - // Find the channel this WHO reply belongs to - const channelData = serverData.channels.find((c) => c.name === channel); - if (!channelData) { - return; - } - - // Parse channel status from flags (e.g., "@" means operator) - let channelStatus = ""; - - if (flags) { - // Extract channel status prefixes from flags - const statusChars = flags.match(/[~&@%+]/g); - if (statusChars) { - channelStatus = statusChars.join(""); - } - } - - // Create user object from WHO data with proper User type - const user: User = { - id: nick, - username: nick, - hostname: host, // Store the hostname from WHO reply - realname: realname, // Store the realname/gecos from WHO reply - avatar: undefined, - isOnline: true, - isAway: isAway, - isBot: false, - isIrcOp: flags ? flags.includes("*") : false, // Check for IRC operator flag - status: channelStatus, // Set the channel status here - metadata: {}, - }; - // Check for bot flags if bot mode is enabled - if (serverData.botMode) { - const botFlag = serverData.botMode; - const isBot = flags.includes(botFlag); - - if (isBot) { - user.isBot = true; - user.metadata = { - bot: { value: "true", visibility: "public" }, - }; - } - } - - // Load saved metadata for this user from localStorage - const savedMetadata = loadSavedMetadata(); - if (savedMetadata[serverId]?.[nick]) { - user.metadata = { - ...user.metadata, - ...savedMetadata[serverId][nick], - }; - } - - // Update the channel's user list with this user - useStore.setState((state) => { - const updatedServers = state.servers.map((s) => { - if (s.id === serverId) { - // Update channels - const updatedChannels = s.channels.map((ch) => { - if (ch.name === channel) { - // Check if user already exists in the list - const existingUserIndex = ch.users.findIndex( - (u) => u.username === nick, - ); - - if (existingUserIndex !== -1) { - // Update existing user - const updatedUsers = [...ch.users]; - updatedUsers[existingUserIndex] = { - ...updatedUsers[existingUserIndex], - ...user, - metadata: { - ...updatedUsers[existingUserIndex].metadata, - ...user.metadata, - }, - }; - return { ...ch, users: updatedUsers }; - } - // Add new user - return { ...ch, users: [...ch.users, user] }; - } - return ch; - }); - - // Also update private chats if this user has a PM tab open - const updatedPrivateChats = s.privateChats.map((pm) => { - if (pm.username.toLowerCase() === nick.toLowerCase()) { - // Update the PM tab with realname from WHO - return { - ...pm, - realname: realname, - }; - } - return pm; - }); - - return { - ...s, - channels: updatedChannels, - privateChats: updatedPrivateChats, - }; - } - return s; - }); - - return { servers: updatedServers }; - }); - }, -); - -ircClient.on("WHO_END", ({ serverId, mask }) => { - // When WHO list is complete for a channel, request metadata for all users - // This ensures we get current metadata for users who were already in the channel - const state = useStore.getState(); - const serverData = state.servers.find((s) => s.id === serverId); - if (!serverData) return; - - // Find the channel (mask should be the channel name) - const channelData = serverData.channels.find((c) => c.name === mask); - - if (channelData) { - // This was a WHO for a channel - // Only request metadata if server supports it - if (serverSupportsMetadata(serverId)) { - // Request metadata for all users in the channel - channelData.users.forEach((user) => { - // Only request if we don't already have metadata for this user - const hasMetadata = - user.metadata && Object.keys(user.metadata).length > 0; - if (!hasMetadata) { - useStore.getState().metadataList(serverId, user.username); - } - }); - } - } else { - // This might be a WHO for an individual user (private chat) - // If we got no WHO_REPLY before this WHO_END, the user is offline - const privateChat = serverData.privateChats?.find( - (pm) => pm.username.toLowerCase() === mask.toLowerCase(), - ); - - if (privateChat) { - // Check if we got a WHO_REPLY for this user by checking their online status - // If they're still marked as offline after WHO_END, they're truly offline - useStore.setState((state) => { - const updatedServers = state.servers.map((s) => { - if (s.id === serverId) { - const updatedPrivateChats = s.privateChats?.map((pm) => { - if (pm.username.toLowerCase() === mask.toLowerCase()) { - // If no WHO_REPLY was received, isOnline would still be false - // Keep it that way and mark as not away - if (!pm.isOnline) { - return { ...pm, isOnline: false, isAway: false }; - } - } - return pm; - }); - return { ...s, privateChats: updatedPrivateChats }; - } - return s; - }); - return { servers: updatedServers }; - }); - } - } -}); - -// WHOX reply handler - for WHO responses with account information -ircClient.on( - "WHOX_REPLY", - ({ - serverId, - channel, - username, - host, - nick, - account, - flags, - realname, - isAway, - opLevel, - }) => { - const state = useStore.getState(); - const serverData = state.servers.find((s) => s.id === serverId); - if (!serverData) return; - - // Determine flags once - const isBotFromFlags = flags.includes("B"); - const isIrcOpFromFlags = flags.includes("*"); - const accountValue = account === "0" ? undefined : account; - - useStore.setState((state) => { - const updatedServers = state.servers.map((s) => { - if (s.id === serverId) { - let updatedPrivateChats = s.privateChats || []; - let updatedChannels = s.channels; - - // Update private chat with account and realname information - const privateChatIndex = updatedPrivateChats.findIndex( - (pm) => pm.username.toLowerCase() === nick.toLowerCase(), - ); - if (privateChatIndex !== -1) { - const existingPm = updatedPrivateChats[privateChatIndex]; - const isBot = existingPm.isBot || isBotFromFlags; - - // Only update if something actually changed - if ( - existingPm.realname !== realname || - existingPm.account !== accountValue || - existingPm.isOnline !== true || - existingPm.isAway !== isAway || - existingPm.isBot !== isBot || - existingPm.isIrcOp !== isIrcOpFromFlags - ) { - updatedPrivateChats = [...updatedPrivateChats]; - updatedPrivateChats[privateChatIndex] = { - ...existingPm, - realname: realname, - account: accountValue, - isOnline: true, - isAway: isAway, - isBot: isBot, - isIrcOp: isIrcOpFromFlags, - }; - } - } - - // Update/add channel users from WHOX response - updatedChannels = updatedChannels.map((ch) => { - // Only update the specific channel from the WHOX response - if (ch.name === channel) { - // Check if user already exists in this channel - const existingUserIndex = ch.users.findIndex( - (user) => user.username.toLowerCase() === nick.toLowerCase(), - ); - - if (existingUserIndex !== -1) { - // Update existing user - const existingUser = ch.users[existingUserIndex]; - const isBot = existingUser.isBot || isBotFromFlags; - - // Only update if something actually changed - if ( - existingUser.hostname !== host || - existingUser.realname !== realname || - existingUser.account !== accountValue || - existingUser.isAway !== isAway || - existingUser.isBot !== isBot || - existingUser.isIrcOp !== isIrcOpFromFlags || - existingUser.status !== (opLevel || existingUser.status) - ) { - const updatedUsers = [...ch.users]; - updatedUsers[existingUserIndex] = { - ...existingUser, - hostname: host, - realname: realname, - account: accountValue, - isAway: isAway, - isBot: isBot, - isIrcOp: isIrcOpFromFlags, - status: opLevel || existingUser.status, - }; - return { ...ch, users: updatedUsers }; - } - } else { - // Add new user to channel - const newUser: User = { - id: `${nick}-${serverId}`, - username: nick, - hostname: host, - realname: realname, - account: accountValue, - isOnline: true, - isAway: isAway, - isBot: isBotFromFlags, - isIrcOp: isIrcOpFromFlags, - status: opLevel, - metadata: {}, - }; - return { ...ch, users: [...ch.users, newUser] }; - } - } - return ch; - }); - - return { - ...s, - privateChats: updatedPrivateChats, - channels: updatedChannels, - }; - } - return s; - }); - return { servers: updatedServers }; - }); - - // Update currentUser if this WHOX reply is for the current user - // NOTE: We parse IRC op flags from WHO responses about ourselves for logging, - // but don't update our own state from WHO replies - that should come from MODE events - const currentUser = state.currentUser; - if ( - currentUser && - currentUser.username.toLowerCase() === nick.toLowerCase() - ) { - // Don't update currentUser from WHO replies - only from authoritative MODE events - return; - } - - // If user is away and we have a pinned PM with them, send WHOIS to get away message - const privateChat = serverData.privateChats?.find( - (pm) => pm.username.toLowerCase() === nick.toLowerCase() && pm.isPinned, - ); - - if (isAway && privateChat) { - // Send WHOIS to get the away message - setTimeout(() => { - ircClient.sendRaw(serverId, `WHOIS ${nick}`); - }, 50); - } - }, -); - -ircClient.on("WHOIS_BOT", ({ serverId, target }) => { - // Update user objects in channels - useStore.setState((state) => { - const updatedServers = state.servers.map((s) => { - if (s.id === serverId) { - const updatedChannels = s.channels.map((channel) => { - const updatedUsers = channel.users.map((user) => { - if (user.username === target) { - return { - ...user, - isBot: true, // Set the WHOIS-detected bot flag - metadata: { - ...user.metadata, - // Keep bot metadata if it exists, but don't require it for display - bot: user.metadata?.bot || { - value: "true", - visibility: "public", - }, - }, - }; - } - return user; - }); - return { ...channel, users: updatedUsers }; - }); - return { ...s, channels: updatedChannels }; - } - return s; - }); - return { servers: updatedServers }; - }); -}); - -// AWAY event handler for away-notify extension -ircClient.on("AWAY", ({ serverId, username, awayMessage }) => { - useStore.setState((state) => { - const updatedServers = state.servers.map((s) => { - if (s.id === serverId) { - // Update user in all channels they're in - const updatedChannels = s.channels.map((channel) => { - const updatedUsers = channel.users.map((user) => { - if (user.username === username) { - return { - ...user, - isAway: !!awayMessage, - awayMessage: awayMessage || undefined, - }; - } - return user; - }); - return { ...channel, users: updatedUsers }; - }); - return { ...s, channels: updatedChannels }; - } - return s; - }); - - // Update current user if this is us - let updatedCurrentUser = state.currentUser; - if (state.currentUser?.username === username) { - updatedCurrentUser = { - ...state.currentUser, - isAway: !!awayMessage, - awayMessage: awayMessage || undefined, - }; - } - - return { servers: updatedServers, currentUser: updatedCurrentUser }; - }); -}); - -// Handle CHGHOST - update user hostname when it changes -ircClient.on("CHGHOST", ({ serverId, username, newUser, newHost }) => { - useStore.setState((state) => { - const updatedServers = state.servers.map((s) => { - if (s.id === serverId) { - // Update user in all channels they're in - const updatedChannels = s.channels.map((channel) => { - const updatedUsers = channel.users.map((user) => { - if (user.username === username) { - return { - ...user, - hostname: newHost, - }; - } - return user; - }); - return { ...channel, users: updatedUsers }; - }); - - // Update user in server-level users list if present - const updatedServerUsers = s.users.map((user) => { - if (user.username === username) { - return { - ...user, - hostname: newHost, - }; - } - return user; - }); - - return { ...s, channels: updatedChannels, users: updatedServerUsers }; - } - return s; - }); - - // Update current user if this is us - let updatedCurrentUser = state.currentUser; - if (state.currentUser?.username === username) { - updatedCurrentUser = { - ...state.currentUser, - hostname: newHost, - }; - } - - return { servers: updatedServers, currentUser: updatedCurrentUser }; - }); -}); - -// Handle 306 numeric - we are now marked as away -ircClient.on("RPL_NOWAWAY", ({ serverId, message }) => { - useStore.setState((state) => { - const updatedServers = state.servers.map((s) => { - if (s.id === serverId) { - return { - ...s, - isAway: true, - awayMessage: message, - }; - } - return s; - }); - - // Update current user if this is the selected server - let updatedCurrentUser = state.currentUser; - if (state.ui.selectedServerId === serverId && state.currentUser) { - updatedCurrentUser = { - ...state.currentUser, - isAway: true, - awayMessage: message, - }; - } - - return { servers: updatedServers, currentUser: updatedCurrentUser }; - }); -}); - -// Handle 305 numeric - we are no longer marked as away -ircClient.on("RPL_UNAWAY", ({ serverId, message }) => { - useStore.setState((state) => { - const updatedServers = state.servers.map((s) => { - if (s.id === serverId) { - return { - ...s, - isAway: false, - awayMessage: undefined, - }; - } - return s; - }); - - // Update current user if this is the selected server - let updatedCurrentUser = state.currentUser; - if (state.ui.selectedServerId === serverId && state.currentUser) { - updatedCurrentUser = { - ...state.currentUser, - isAway: false, - awayMessage: undefined, - }; - } - - return { servers: updatedServers, currentUser: updatedCurrentUser }; - }); -}); - -// Batch event handlers -ircClient.on("BATCH_START", ({ serverId, batchId, type, parameters }) => { - useStore.setState((state) => { - const serverBatches = state.activeBatches[serverId] || {}; - return { - activeBatches: { - ...state.activeBatches, - [serverId]: { - ...serverBatches, - [batchId]: { - type, - parameters: parameters || [], - events: [], - startTime: new Date(), - }, - }, - }, - }; - }); -}); - -ircClient.on("BATCH_END", ({ serverId, batchId }) => { - useStore.setState((state) => { - const serverBatches = state.activeBatches[serverId]; - if (!serverBatches || !serverBatches[batchId]) { - return state; - } - - const batch = serverBatches[batchId]; - - // Process the batch based on its type - if (batch.type === "netsplit") { - processBatchedNetsplit(serverId, batchId, batch); - } else if (batch.type === "netjoin") { - processBatchedNetjoin(serverId, batchId, batch); - } else if (batch.type === "draft/multiline" || batch.type === "multiline") { - // Multiline batches are handled by the IRC client directly via MULTILINE_MESSAGE events - // Don't process individual events here, the IRC client already combined them - } else if (batch.type === "metadata") { - // Metadata batches are handled by the IRC client directly via individual METADATA events - // Don't process individual events here, metadata updates are already processed - } else if (batch.type === "chathistory") { - // Chathistory batch completed - turn off loading state for the channel - - // Try to determine the channel from batch parameters - // Chathistory batch parameters typically include the channel name - const channelName = - batch.parameters && batch.parameters.length > 0 - ? batch.parameters[0] - : null; - - if (channelName) { - // Trigger event to turn off loading state - ircClient.triggerEvent("CHATHISTORY_LOADING", { - serverId, - channelName, - isLoading: false, - }); - } - } else { - // For unknown batch types, process events individually - batch.events.forEach((event) => { - // Re-trigger the event without batch context based on its type - switch (event.type) { - case "JOIN": - ircClient.triggerEvent("JOIN", event.data); - break; - case "QUIT": - ircClient.triggerEvent("QUIT", event.data); - break; - case "PART": - ircClient.triggerEvent("PART", event.data); - break; - } - }); - } - - // Remove the completed batch - const { [batchId]: removed, ...remainingBatches } = serverBatches; - return { - activeBatches: { - ...state.activeBatches, - [serverId]: remainingBatches, - }, - }; - }); -}); - -// NAMES reply handler - when we get the initial user list for a channel -ircClient.on("NAMES", ({ serverId, channelName, users }) => { - useStore.setState((state) => { - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - const updatedChannels = server.channels.map((channel) => { - if (channel.name.toLowerCase() === channelName.toLowerCase()) { - // Add users from NAMES reply to the channel - const existingUsernames = new Set( - channel.users.map((u) => u.username.toLowerCase()), - ); - - const newUsers = users - .filter( - (user) => !existingUsernames.has(user.username.toLowerCase()), - ) - .map((user) => { - // Check if we already have metadata for this user from localStorage or other channels - let existingMetadata = {}; - - // First check localStorage - const savedMetadata = loadSavedMetadata(); - const serverMetadata = savedMetadata[serverId]; - if (serverMetadata?.[user.username]) { - existingMetadata = { ...serverMetadata[user.username] }; - } - - // Then check if user exists in other channels and has metadata - if (Object.keys(existingMetadata).length === 0) { - for (const otherChannel of server.channels) { - if ( - otherChannel.name.toLowerCase() !== - channelName.toLowerCase() - ) { - const existingUser = otherChannel.users.find( - (u) => - u.username.toLowerCase() === - user.username.toLowerCase(), - ); - if ( - existingUser?.metadata && - Object.keys(existingUser.metadata).length > 0 - ) { - existingMetadata = { ...existingUser.metadata }; - break; - } - } - } - } - - return { - ...user, - id: uuidv4(), - isOnline: true, - metadata: existingMetadata, - }; - }); - - return { - ...channel, - users: [...channel.users, ...newUsers], - }; - } - return channel; - }); - - return { - ...server, - channels: updatedChannels, - }; - } - return server; - }); - - // Request metadata for users who don't have it yet - if (serverSupportsMetadata(serverId)) { - const serverData = state.servers.find((s) => s.id === serverId); - const channelData = serverData?.channels.find( - (c) => c.name.toLowerCase() === channelName.toLowerCase(), - ); - - if (channelData) { - // Request metadata for users who don't have it - channelData.users.forEach((user) => { - const hasMetadata = - user.metadata && Object.keys(user.metadata).length > 0; - if (!hasMetadata) { - useStore.getState().metadataList(serverId, user.username); - } - }); - } - } - - // Check if current user has operator status in this channel and update their modes - const currentUser = state.currentUser; - if (currentUser) { - const currentUserInChannel = users.find( - (user) => - user.username.toLowerCase() === currentUser.username.toLowerCase(), - ); - if (currentUserInChannel?.status) { - // Check if user has operator status (contains '@' or other operator prefixes) - const hasOperatorStatus = - currentUserInChannel.status.includes("@") || - currentUserInChannel.status.includes("~") || - currentUserInChannel.status.includes("&"); - if ( - hasOperatorStatus && - (!currentUser.modes || !currentUser.modes.includes("o")) - ) { - // Update currentUser with operator modes - return { - servers: updatedServers, - currentUser: { - ...currentUser, - modes: currentUser.modes ? `${currentUser.modes}o` : "o", - }, - }; - } - } - } - - return { servers: updatedServers }; - }); -}); +// Export types for use in other modules +// Export types from types.ts +export type { + AppState, + ChannelListEntry, + ChannelListFilters, + GlobalNotification, + GlobalSettings, + layoutColumn, + UIState, +} from "./types"; +// Default export export default useStore; diff --git a/src/store/middleware/persistConfig.ts b/src/store/middleware/persistConfig.ts new file mode 100644 index 00000000..f47933f4 --- /dev/null +++ b/src/store/middleware/persistConfig.ts @@ -0,0 +1,138 @@ +import type { PersistOptions } from "zustand/middleware"; +import type { AppState } from "../types"; + +/** + * Centralized persist configuration for the store + * Replaces all manual localStorage operations + */ +export const persistConfig: PersistOptions = { + name: "obsidian-irc-storage", + version: 1, + + // Select only what should be persisted + partialize: (state) => { + return { + // Settings - persist all settings + globalSettings: state.globalSettings, + + // Servers - persist server list and configurations including credentials + servers: state.servers?.map((server) => ({ + id: server.id, + name: server.name, + host: server.host, + port: server.port, + nickname: server.nickname, + password: server.password, + saslAccountName: server.saslAccountName, + saslPassword: server.saslPassword, + saslEnabled: server.saslEnabled, + // Don't persist connection state or live data + // These will be restored on reconnect + })), + + // Channel order - persist user's channel organization + channelOrder: state.channelOrder, + + // Pinned private chats + // Extract pinned chats from servers + pinnedPrivateChats: (() => { + const pinned: Record< + string, + Array<{ username: string; order: number }> + > = {}; + if (state.servers) { + for (const server of state.servers) { + if (server.privateChats) { + const pinnedChats = server.privateChats + .filter((pc) => pc.isPinned) + .map((pc) => ({ + username: pc.username, + order: pc.order || 0, + })); + if (pinnedChats.length > 0) { + pinned[server.id] = pinnedChats; + } + } + } + } + return pinned; + })(), + + // Metadata cache (for performance) + channelMetadataCache: state.channelMetadataCache, + + // Don't persist: + // - messages (too large, fetched from server) + // - typingUsers (ephemeral) + // - globalNotifications (ephemeral) + // - ui state (ephemeral) + // - isConnecting, connectionError (transient) + // - metadataBatches, activeBatches (transient) + } as unknown as AppState; + }, + + // Migration function for version changes + migrate: (persistedState: unknown, version: number) => { + // Handle migrations from old store structure + if (version === 0) { + // Version 0 → 1: Migrate from old monolithic structure + // Map old localStorage keys to new structure + + const migrated = { ...(persistedState as Record) }; + + // Migrate old savedServers if exists + try { + const oldServers = localStorage.getItem("savedServers"); + if (oldServers && !migrated.servers) { + migrated.servers = JSON.parse(oldServers); + } + } catch (e) { + console.error("Migration error for savedServers:", e); + } + + // Migrate old settings if exists + try { + const oldSettings = localStorage.getItem("globalSettings"); + if (oldSettings && !migrated.globalSettings) { + migrated.globalSettings = JSON.parse(oldSettings); + } + } catch (e) { + console.error("Migration error for globalSettings:", e); + } + + // Migrate old channel order if exists + try { + const oldChannelOrder = localStorage.getItem("channelOrder"); + if (oldChannelOrder && !migrated.channelOrder) { + migrated.channelOrder = JSON.parse(oldChannelOrder); + } + } catch (e) { + console.error("Migration error for channelOrder:", e); + } + + // Migrate old pinned chats if exists + try { + const oldPinnedChats = localStorage.getItem("pinnedPrivateChats"); + if (oldPinnedChats && !migrated.pinnedPrivateChats) { + migrated.pinnedPrivateChats = JSON.parse(oldPinnedChats); + } + } catch (e) { + console.error("Migration error for pinnedPrivateChats:", e); + } + + return migrated as unknown as AppState; + } + + return persistedState as unknown as AppState; + }, + + // Merge function to handle hydration + merge: (persistedState, currentState) => { + // Merge persisted state into current state + // This runs when the store is initialized + return { + ...currentState, + ...(persistedState as object), + }; + }, +}; diff --git a/src/store/slices/channelSlice.ts b/src/store/slices/channelSlice.ts new file mode 100644 index 00000000..d50658ad --- /dev/null +++ b/src/store/slices/channelSlice.ts @@ -0,0 +1,254 @@ +import type { StateCreator } from "zustand"; +import type { + ChannelListEntry, + ChannelListFilters, + ChannelMetadata, + ChannelOrderMap, +} from "../types"; + +export interface ChannelSlice { + channelOrder: ChannelOrderMap; + channelList: Record; + channelListBuffer: Record; + channelListFilters: Record; + listingInProgress: Record; + channelMetadataCache: Record>; + channelMetadataFetchQueue: Record>; + + // Channel order operations + reorderChannels: (serverId: string, channelNames: string[]) => void; + getChannelOrder: (serverId: string) => string[]; + + // Channel list operations (for /LIST command) + setChannelList: (serverId: string, channels: ChannelListEntry[]) => void; + clearChannelList: (serverId: string) => void; + setChannelListBuffer: ( + serverId: string, + channels: ChannelListEntry[], + ) => void; + appendToChannelListBuffer: ( + serverId: string, + channel: ChannelListEntry, + ) => void; + finalizeChannelList: (serverId: string) => void; + + // Channel list filters + updateChannelListFilters: ( + serverId: string, + filters: ChannelListFilters, + ) => void; + getChannelListFilters: (serverId: string) => ChannelListFilters; + + // Listing status + setListingInProgress: (serverId: string, inProgress: boolean) => void; + isListingInProgress: (serverId: string) => boolean; + + // Channel metadata cache + cacheChannelMetadata: ( + serverId: string, + channelName: string, + metadata: Partial, + ) => void; + getChannelMetadata: ( + serverId: string, + channelName: string, + ) => ChannelMetadata | undefined; + addToMetadataFetchQueue: (serverId: string, channelName: string) => void; + removeFromMetadataFetchQueue: (serverId: string, channelName: string) => void; + isInMetadataFetchQueue: (serverId: string, channelName: string) => boolean; + + // Channel operations (work with server slice) + markChannelAsRead: (serverId: string, channelId: string) => void; + updateChannelUnreadCount: ( + serverId: string, + channelId: string, + count: number, + ) => void; + setChannelMentioned: ( + serverId: string, + channelId: string, + isMentioned: boolean, + ) => void; +} + +export const createChannelSlice: StateCreator< + ChannelSlice, + [ + ["zustand/devtools", never], + ["zustand/persist", unknown], + ["zustand/immer", never], + ], + [], + ChannelSlice +> = (set, get) => ({ + channelOrder: {}, + channelList: {}, + channelListBuffer: {}, + channelListFilters: {}, + listingInProgress: {}, + channelMetadataCache: {}, + channelMetadataFetchQueue: {}, + + reorderChannels: (serverId, channelNames) => + set( + (state) => { + state.channelOrder[serverId] = channelNames; + }, + false, + "channel/reorder", + ), + + getChannelOrder: (serverId) => { + return get().channelOrder[serverId] || []; + }, + + setChannelList: (serverId, channels) => + set( + (state) => { + state.channelList[serverId] = channels; + }, + false, + "channel/list/set", + ), + + clearChannelList: (serverId) => + set( + (state) => { + state.channelList[serverId] = []; + state.channelListBuffer[serverId] = []; + }, + false, + "channel/list/clear", + ), + + setChannelListBuffer: (serverId, channels) => + set( + (state) => { + state.channelListBuffer[serverId] = channels; + }, + false, + "channel/list/buffer/set", + ), + + appendToChannelListBuffer: (serverId, channel) => + set( + (state) => { + if (!state.channelListBuffer[serverId]) { + state.channelListBuffer[serverId] = []; + } + state.channelListBuffer[serverId].push(channel); + }, + false, + "channel/list/buffer/append", + ), + + finalizeChannelList: (serverId) => + set( + (state) => { + state.channelList[serverId] = state.channelListBuffer[serverId] || []; + state.channelListBuffer[serverId] = []; + }, + false, + "channel/list/finalize", + ), + + updateChannelListFilters: (serverId, filters) => + set( + (state) => { + state.channelListFilters[serverId] = filters; + }, + false, + "channel/list/filters/update", + ), + + getChannelListFilters: (serverId) => { + return get().channelListFilters[serverId] || {}; + }, + + setListingInProgress: (serverId, inProgress) => + set( + (state) => { + state.listingInProgress[serverId] = inProgress; + }, + false, + "channel/list/status", + ), + + isListingInProgress: (serverId) => { + return get().listingInProgress[serverId] || false; + }, + + cacheChannelMetadata: (serverId, channelName, metadata) => + set( + (state) => { + if (!state.channelMetadataCache[serverId]) { + state.channelMetadataCache[serverId] = {}; + } + const existing = + state.channelMetadataCache[serverId][channelName] || {}; + state.channelMetadataCache[serverId][channelName] = { + ...existing, + ...metadata, + fetchedAt: Date.now(), + }; + }, + false, + "channel/metadata/cache", + ), + + getChannelMetadata: (serverId, channelName) => { + return get().channelMetadataCache[serverId]?.[channelName]; + }, + + addToMetadataFetchQueue: (serverId, channelName) => + set( + (state) => { + if (!state.channelMetadataFetchQueue[serverId]) { + state.channelMetadataFetchQueue[serverId] = new Set(); + } + state.channelMetadataFetchQueue[serverId].add(channelName); + }, + false, + "channel/metadata/queue/add", + ), + + removeFromMetadataFetchQueue: (serverId, channelName) => + set( + (state) => { + state.channelMetadataFetchQueue[serverId]?.delete(channelName); + }, + false, + "channel/metadata/queue/remove", + ), + + isInMetadataFetchQueue: (serverId, channelName) => { + return get().channelMetadataFetchQueue[serverId]?.has(channelName) || false; + }, + + markChannelAsRead: (serverId, channelId) => + set( + (state) => { + // Will be implemented with server slice access + }, + false, + "channel/markRead", + ), + + updateChannelUnreadCount: (serverId, channelId, count) => + set( + (state) => { + // Will be implemented with server slice access + }, + false, + "channel/unreadCount", + ), + + setChannelMentioned: (serverId, channelId, isMentioned) => + set( + (state) => { + // Will be implemented with server slice access + }, + false, + "channel/mentioned", + ), +}); diff --git a/src/store/slices/ircActionsSlice.ts b/src/store/slices/ircActionsSlice.ts new file mode 100644 index 00000000..2f3b82d5 --- /dev/null +++ b/src/store/slices/ircActionsSlice.ts @@ -0,0 +1,445 @@ +import { v4 as uuidv4 } from "uuid"; +import type { StateCreator } from "zustand"; +import { ircClient } from "../../lib/ircClient"; +import type { AppState } from "../types"; + +/** + * IRC Actions Slice + * + * Provides IRC protocol commands and UI convenience methods. + * These methods interact with the IRC client and coordinate state updates. + */ + +export interface IRCActionsSlice { + // Connection management + connect: ( + name: string, + host: string, + port: number, + nickname: string, + saslEnabled: boolean, + password?: string, + saslAccountName?: string, + saslPassword?: string, + registerAccount?: boolean, + registerEmail?: string, + registerPassword?: string, + ) => Promise; + disconnect: (serverId: string) => void; + reconnectServer: (serverId: string) => Promise; + connectToSavedServers: () => void; + deleteServer: (serverId: string) => void; + + // Channel operations + joinChannel: (serverId: string, channelName: string) => void; + leaveChannel: (serverId: string, channelName: string) => void; + listChannels: ( + serverId: string, + filters?: { + minUsers?: number; + maxUsers?: number; + minCreationTime?: number; + maxTopicTime?: number; + pattern?: string; + notPattern?: string; + }, + ) => void; + renameChannel: ( + serverId: string, + oldName: string, + newName: string, + reason?: string, + ) => void; + + // Message operations + sendMessage: (serverId: string, channelId: string, content: string) => void; + redactMessage: ( + serverId: string, + channelId: string, + messageId: string, + ) => void; + + // User moderation + warnUser: ( + serverId: string, + channelName: string, + username: string, + reason?: string, + ) => void; + kickUser: ( + serverId: string, + channelName: string, + username: string, + reason?: string, + ) => void; + banUserByNick: ( + serverId: string, + channelName: string, + username: string, + reason?: string, + ) => void; + banUserByHostmask: ( + serverId: string, + channelName: string, + hostmask: string, + reason?: string, + ) => void; + + // User operations + setName: (serverId: string, realname: string) => void; + changeNick: (serverId: string, newNick: string) => void; + + // IRC metadata operations + metadataSet: ( + serverId: string, + target: string, + key: string, + value: string, + visibility?: string, + ) => void; + metadataGet: ( + serverId: string, + target: string, + keys: string | string[], + ) => void; + + // Raw IRC commands + sendRaw: (serverId: string, command: string) => void; + + // Selection/navigation helpers + selectServer: (serverId: string | null) => void; + selectChannel: (channelId: string | null) => void; + selectPrivateChat: (privateChatId: string | null) => void; + openPrivateChat: (serverId: string, username: string) => void; +} + +export const createIRCActionsSlice: StateCreator< + AppState, + [ + ["zustand/devtools", never], + ["zustand/persist", unknown], + ["zustand/immer", never], + ], + [], + IRCActionsSlice +> = (set, get) => ({ + connect: async ( + name, + host, + port, + nickname, + _saslEnabled, + password, + saslAccountName, + saslPassword, + ) => { + // The IRC client will handle the connection and fire events + // that the IRC adapter will handle to update state + try { + const server = await ircClient.connect( + name, + host, + port, + nickname, + password, + saslAccountName, + saslPassword, + ); + + // Add server to Zustand store if it doesn't exist + const state = get(); + const existingServer = state.getServer(server.id); + if (!existingServer) { + state.addServer(server); + } + + // Select the newly connected server + state.setSelectedServerId(server.id); + } catch (error) { + console.error("Connection error:", error); + throw error; + } + }, + + disconnect: (serverId) => { + ircClient.disconnect(serverId); + }, + + reconnectServer: async (serverId) => { + const state = get(); + const server = state.getServer(serverId); + if (!server) { + console.error(`Server ${serverId} not found`); + return; + } + + // Update server state to connecting + state.setConnectionState(serverId, "connecting"); + + try { + // Get saved server config to get credentials + const { loadSavedServers } = await import("../index"); + const savedServers = loadSavedServers(); + const savedServer = savedServers.find( + (s) => s.host === server.host && s.port === server.port, + ); + + if (!savedServer) { + console.error(`No saved configuration found for server ${serverId}`); + throw new Error(`No saved configuration found for server ${serverId}`); + } + + // Reconnect using saved credentials + await get().connect( + savedServer.name || savedServer.host, + savedServer.host, + savedServer.port, + savedServer.nickname || "user", + savedServer.saslEnabled || false, + savedServer.password, + savedServer.saslAccountName, + savedServer.saslPassword, + ); + } catch (error) { + console.error(`Failed to reconnect to server ${serverId}`, error); + state.setConnectionState(serverId, "disconnected"); + throw error; + } + }, + + connectToSavedServers: () => { + const state = get(); + if (state.hasConnectedToSavedServers) { + return; // Already connected, don't do it again + } + + state.setHasConnectedToSavedServers(true); + + (async () => { + const { loadSavedServers } = await import("../index"); + const savedServers = loadSavedServers(); + + for (const savedServer of savedServers) { + const { + host, + port, + name, + nickname, + password, + saslEnabled, + saslAccountName, + saslPassword, + } = savedServer; + + // Check if server already exists in store + const existingServer = state.getServerByHost(host, port); + if (existingServer) { + continue; // Skip if already exists + } + + try { + await get().connect( + name || host, + host, + port, + nickname || "user", + saslEnabled || false, + password, + saslAccountName, + saslPassword, + ); + } catch (error) { + console.error( + `Failed to connect to saved server ${host}:${port}`, + error, + ); + } + } + })(); + }, + + deleteServer: (serverId) => { + set( + (state) => { + state.servers = state.servers.filter((s) => s.id !== serverId); + }, + false, + "irc/deleteServer", + ); + }, + + joinChannel: (serverId, channelName) => { + ircClient.joinChannel(serverId, channelName); + }, + + leaveChannel: (serverId, channelName) => { + ircClient.leaveChannel(serverId, channelName); + }, + + listChannels: (serverId, filters) => { + ircClient.listChannels(serverId, undefined, filters); + }, + + renameChannel: (serverId, oldName, newName, reason) => { + ircClient.renameChannel(serverId, oldName, newName, reason); + }, + + sendMessage: (serverId, channelId, content) => { + const state = get(); + const channel = state.getChannel(serverId, channelId); + if (channel) { + ircClient.sendMessage(serverId, channel.name, content); + } + }, + + redactMessage: (serverId, channelId, messageId) => { + const state = get(); + const channel = state.getChannel(serverId, channelId); + if (channel) { + ircClient.sendRedact( + serverId, + channel.name, + messageId, + "Message redacted", + ); + } + }, + + warnUser: (serverId, channelName, username, reason) => { + // Send a warning message to the user via PRIVMSG + const warningMessage = `Warning: ${reason || "Please follow channel rules"}`; + ircClient.sendRaw(serverId, `PRIVMSG ${username} :${warningMessage}`); + }, + + kickUser: (serverId, channelName, username, reason) => { + const kickCommand = `KICK ${channelName} ${username}${reason ? ` :${reason}` : ""}`; + ircClient.sendRaw(serverId, kickCommand); + }, + + banUserByNick: (serverId, channelName, username, reason) => { + // Ban by nickname pattern (username!*@*) + ircClient.sendRaw(serverId, `MODE ${channelName} +b ${username}!*@*`); + ircClient.sendRaw( + serverId, + `KICK ${channelName} ${username}${reason ? ` :${reason}` : ""}`, + ); + }, + + banUserByHostmask: (serverId, channelName, hostmask, reason) => { + const state = get(); + const server = state.getServer(serverId); + if (!server) return; + + const channel = state.getChannelByName(serverId, channelName); + // Try to find the user in the channel's user list first, then fall back to server user list + const user = + channel?.users.find((u) => u.username === hostmask) || + server.users?.find((u) => u.username === hostmask); + + const hostname = user?.hostname || "*"; + ircClient.sendRaw(serverId, `MODE ${channelName} +b *!*@${hostname}`); + ircClient.sendRaw( + serverId, + `KICK ${channelName} ${hostmask}${reason ? ` :${reason}` : ""}`, + ); + }, + + setName: (serverId, realname) => { + ircClient.setName(serverId, realname); + }, + + changeNick: (serverId, newNick) => { + ircClient.changeNick(serverId, newNick); + }, + + metadataSet: (serverId, target, key, value, visibility) => { + ircClient.metadataSet(serverId, target, key, value, visibility); + }, + + metadataGet: (serverId, target, keys) => { + const keyArray = Array.isArray(keys) ? keys : [keys]; + ircClient.metadataGet(serverId, target, keyArray); + }, + + sendRaw: (serverId, command) => { + ircClient.sendRaw(serverId, command); + }, + + selectServer: (serverId) => { + set( + (state) => { + state.ui.selectedServerId = serverId; + }, + false, + "irc/selectServer", + ); + }, + + selectChannel: (channelId) => { + set( + (state) => { + if (!state.ui.selectedServerId) return; + + const currentSelection = + state.ui.perServerSelections[state.ui.selectedServerId] || {}; + state.ui.perServerSelections[state.ui.selectedServerId] = { + ...currentSelection, + selectedChannelId: channelId, + selectedPrivateChatId: null, + }; + }, + false, + "irc/selectChannel", + ); + }, + + selectPrivateChat: (privateChatId) => { + set( + (state) => { + if (!state.ui.selectedServerId) return; + + const currentSelection = + state.ui.perServerSelections[state.ui.selectedServerId] || {}; + state.ui.perServerSelections[state.ui.selectedServerId] = { + ...currentSelection, + selectedChannelId: null, + selectedPrivateChatId: privateChatId, + }; + }, + false, + "irc/selectPrivateChat", + ); + }, + + openPrivateChat: (serverId, username) => { + set( + (state) => { + // Find or create private chat + let privateChat = state.getPrivateChatByUsername(serverId, username); + if (!privateChat) { + privateChat = { + id: uuidv4(), + username, + serverId, + unreadCount: 0, + isMentioned: false, + isPinned: false, + order: 0, + }; + state.addPrivateChatToServer(serverId, privateChat); + } + + // Select it + state.ui.selectedServerId = serverId; + const currentSelection = state.ui.perServerSelections[serverId] || {}; + state.ui.perServerSelections[serverId] = { + ...currentSelection, + selectedChannelId: null, + selectedPrivateChatId: privateChat.id, + }; + }, + false, + "irc/openPrivateChat", + ); + }, +}); diff --git a/src/store/slices/messageSlice.ts b/src/store/slices/messageSlice.ts new file mode 100644 index 00000000..24555c07 --- /dev/null +++ b/src/store/slices/messageSlice.ts @@ -0,0 +1,161 @@ +import type { StateCreator } from "zustand"; +import type { Message } from "../../types"; +import type { AppState } from "../types"; + +export interface MessageSlice { + messages: Record; + processedMessageIds: Set; + + addMessage: (message: Message) => void; + getChannelMessages: (serverId: string, channelId: string) => Message[]; + findMessageById: ( + serverId: string, + channelId: string, + messageId: string, + ) => Message | undefined; + updateMessage: ( + serverId: string, + channelId: string, + messageId: string, + updates: Partial, + ) => void; + deleteMessage: ( + serverId: string, + channelId: string, + messageId: string, + ) => void; + clearChannelMessages: (serverId: string, channelId: string) => void; + markMessageAsProcessed: (msgid: string) => void; + isMessageProcessed: (msgid: string) => boolean; +} + +export const createMessageSlice: StateCreator< + AppState, + [ + ["zustand/devtools", never], + ["zustand/persist", unknown], + ["zustand/immer", never], + ], + [], + MessageSlice +> = (set, get) => ({ + messages: {}, + processedMessageIds: new Set(), + + addMessage: (message) => + set( + (state) => { + const channelKey = `${message.serverId}-${message.channelId}`; + + if (!state.messages[channelKey]) { + state.messages[channelKey] = []; + } + + const currentMessages = state.messages[channelKey]; + + // Check for duplicate messages + const isDuplicate = currentMessages.some( + (existingMessage) => + existingMessage.id === message.id || + (existingMessage.content === message.content && + existingMessage.timestamp === message.timestamp && + existingMessage.userId === message.userId), + ); + + if (isDuplicate) { + return; + } + + // Add message and sort by timestamp + // Note: Using type assertion to avoid TypeScript's type instantiation depth limit with Immer + const messagesArray = currentMessages as unknown as Message[]; + messagesArray.push(message as Message); + messagesArray.sort((a, b) => { + const timeA = + a.timestamp instanceof Date + ? a.timestamp.getTime() + : new Date(a.timestamp).getTime(); + const timeB = + b.timestamp instanceof Date + ? b.timestamp.getTime() + : new Date(b.timestamp).getTime(); + return timeA - timeB; + }); + + // Mark as processed if it has a msgid + if (message.msgid) { + state.processedMessageIds.add(message.msgid); + } + }, + false, + "message/add", + ), + + getChannelMessages: (serverId, channelId) => { + const key = `${serverId}-${channelId}`; + return get().messages[key] || []; + }, + + findMessageById: (serverId, channelId, messageId) => { + const messages = get().getChannelMessages(serverId, channelId); + return messages.find((message) => message.msgid === messageId); + }, + + updateMessage: (serverId, channelId, messageId, updates) => + set( + (state) => { + const channelKey = `${serverId}-${channelId}`; + const channelMessages = state.messages[channelKey]; + + if (channelMessages) { + const messageIndex = channelMessages.findIndex( + (m) => m.msgid === messageId, + ); + if (messageIndex !== -1) { + Object.assign(state.messages[channelKey][messageIndex], updates); + } + } + }, + false, + "message/update", + ), + + deleteMessage: (serverId, channelId, messageId) => + set( + (state) => { + const channelKey = `${serverId}-${channelId}`; + const channelMessages = state.messages[channelKey]; + + if (channelMessages) { + state.messages[channelKey] = channelMessages.filter( + (m) => m.msgid !== messageId, + ); + } + }, + false, + "message/delete", + ), + + clearChannelMessages: (serverId, channelId) => + set( + (state) => { + const channelKey = `${serverId}-${channelId}`; + delete state.messages[channelKey]; + }, + false, + "message/clear", + ), + + markMessageAsProcessed: (msgid) => + set( + (state) => { + state.processedMessageIds.add(msgid); + }, + false, + "message/markProcessed", + ), + + isMessageProcessed: (msgid) => { + return get().processedMessageIds.has(msgid); + }, +}); diff --git a/src/store/slices/metadataSlice.ts b/src/store/slices/metadataSlice.ts new file mode 100644 index 00000000..77607c63 --- /dev/null +++ b/src/store/slices/metadataSlice.ts @@ -0,0 +1,266 @@ +import type { StateCreator } from "zustand"; +import type { WhoisData } from "../../types"; +import type { BatchInfo, MetadataBatch, PendingRegistration } from "../types"; + +export interface MetadataSlice { + metadataSubscriptions: Record; + metadataBatches: Record; + activeBatches: Record>; + metadataFetchInProgress: Record; + userMetadataRequested: Record>; + metadataChangeCounter: number; + whoisData: Record>; + pendingRegistration: PendingRegistration | null; + + // Metadata subscriptions + setMetadataSubscriptions: (serverId: string, keys: string[]) => void; + getMetadataSubscriptions: (serverId: string) => string[]; + addMetadataSubscription: (serverId: string, key: string) => void; + removeMetadataSubscription: (serverId: string, key: string) => void; + + // Metadata batches + setMetadataBatch: (batchId: string, batch: MetadataBatch) => void; + getMetadataBatch: (batchId: string) => MetadataBatch | undefined; + deleteMetadataBatch: (batchId: string) => void; + + // Active batches (IRC batch processing) + setActiveBatch: (serverId: string, batchId: string, batch: BatchInfo) => void; + getActiveBatch: (serverId: string, batchId: string) => BatchInfo | undefined; + deleteActiveBatch: (serverId: string, batchId: string) => void; + clearActiveBatches: (serverId: string) => void; + + // Metadata fetch tracking + setMetadataFetchInProgress: (serverId: string, inProgress: boolean) => void; + isMetadataFetchInProgress: (serverId: string) => boolean; + + // User metadata tracking + markUserMetadataRequested: (serverId: string, username: string) => void; + isUserMetadataRequested: (serverId: string, username: string) => boolean; + clearUserMetadataRequested: (serverId: string) => void; + + // Metadata change counter (for reactivity) + incrementMetadataChangeCounter: () => void; + + // WHOIS data cache + setWhoisData: (serverId: string, nickname: string, data: WhoisData) => void; + getWhoisData: (serverId: string, nickname: string) => WhoisData | undefined; + clearWhoisData: (serverId: string, nickname?: string) => void; + + // Account registration + setPendingRegistration: (registration: PendingRegistration | null) => void; + clearPendingRegistration: () => void; +} + +export const createMetadataSlice: StateCreator< + MetadataSlice, + [ + ["zustand/devtools", never], + ["zustand/persist", unknown], + ["zustand/immer", never], + ], + [], + MetadataSlice +> = (set, get) => ({ + metadataSubscriptions: {}, + metadataBatches: {}, + activeBatches: {}, + metadataFetchInProgress: {}, + userMetadataRequested: {}, + metadataChangeCounter: 0, + whoisData: {}, + pendingRegistration: null, + + setMetadataSubscriptions: (serverId, keys) => + set( + (state) => { + state.metadataSubscriptions[serverId] = keys; + }, + false, + "metadata/subscriptions/set", + ), + + getMetadataSubscriptions: (serverId) => { + return get().metadataSubscriptions[serverId] || []; + }, + + addMetadataSubscription: (serverId, key) => + set( + (state) => { + if (!state.metadataSubscriptions[serverId]) { + state.metadataSubscriptions[serverId] = []; + } + if (!state.metadataSubscriptions[serverId].includes(key)) { + state.metadataSubscriptions[serverId].push(key); + } + }, + false, + "metadata/subscriptions/add", + ), + + removeMetadataSubscription: (serverId, key) => + set( + (state) => { + if (state.metadataSubscriptions[serverId]) { + state.metadataSubscriptions[serverId] = state.metadataSubscriptions[ + serverId + ].filter((k) => k !== key); + } + }, + false, + "metadata/subscriptions/remove", + ), + + setMetadataBatch: (batchId, batch) => + set( + (state) => { + state.metadataBatches[batchId] = batch; + }, + false, + "metadata/batch/set", + ), + + getMetadataBatch: (batchId) => { + return get().metadataBatches[batchId]; + }, + + deleteMetadataBatch: (batchId) => + set( + (state) => { + delete state.metadataBatches[batchId]; + }, + false, + "metadata/batch/delete", + ), + + setActiveBatch: (serverId, batchId, batch) => + set( + (state) => { + if (!state.activeBatches[serverId]) { + state.activeBatches[serverId] = {}; + } + state.activeBatches[serverId][batchId] = batch; + }, + false, + "metadata/activeBatch/set", + ), + + getActiveBatch: (serverId, batchId) => { + return get().activeBatches[serverId]?.[batchId]; + }, + + deleteActiveBatch: (serverId, batchId) => + set( + (state) => { + if (state.activeBatches[serverId]) { + delete state.activeBatches[serverId][batchId]; + } + }, + false, + "metadata/activeBatch/delete", + ), + + clearActiveBatches: (serverId) => + set( + (state) => { + delete state.activeBatches[serverId]; + }, + false, + "metadata/activeBatch/clear", + ), + + setMetadataFetchInProgress: (serverId, inProgress) => + set( + (state) => { + state.metadataFetchInProgress[serverId] = inProgress; + }, + false, + "metadata/fetch/status", + ), + + isMetadataFetchInProgress: (serverId) => { + return get().metadataFetchInProgress[serverId] || false; + }, + + markUserMetadataRequested: (serverId, username) => + set( + (state) => { + if (!state.userMetadataRequested[serverId]) { + state.userMetadataRequested[serverId] = new Set(); + } + state.userMetadataRequested[serverId].add(username); + }, + false, + "metadata/userRequested/mark", + ), + + isUserMetadataRequested: (serverId, username) => { + return get().userMetadataRequested[serverId]?.has(username) || false; + }, + + clearUserMetadataRequested: (serverId) => + set( + (state) => { + delete state.userMetadataRequested[serverId]; + }, + false, + "metadata/userRequested/clear", + ), + + incrementMetadataChangeCounter: () => + set( + (state) => { + state.metadataChangeCounter += 1; + }, + false, + "metadata/counter/increment", + ), + + setWhoisData: (serverId, nickname, data) => + set( + (state) => { + if (!state.whoisData[serverId]) { + state.whoisData[serverId] = {}; + } + state.whoisData[serverId][nickname] = data; + }, + false, + "metadata/whois/set", + ), + + getWhoisData: (serverId, nickname) => { + return get().whoisData[serverId]?.[nickname]; + }, + + clearWhoisData: (serverId, nickname) => + set( + (state) => { + if (nickname) { + if (state.whoisData[serverId]) { + delete state.whoisData[serverId][nickname]; + } + } else { + delete state.whoisData[serverId]; + } + }, + false, + "metadata/whois/clear", + ), + + setPendingRegistration: (registration) => + set( + (state) => { + state.pendingRegistration = registration; + }, + false, + "metadata/registration/set", + ), + + clearPendingRegistration: () => + set( + (state) => { + state.pendingRegistration = null; + }, + false, + "metadata/registration/clear", + ), +}); diff --git a/src/store/slices/notificationSlice.ts b/src/store/slices/notificationSlice.ts new file mode 100644 index 00000000..d8e55cba --- /dev/null +++ b/src/store/slices/notificationSlice.ts @@ -0,0 +1,153 @@ +import { v4 as uuidv4 } from "uuid"; +import type { StateCreator } from "zustand"; +import type { User } from "../../types"; +import type { GlobalNotification } from "../types"; + +export interface NotificationSlice { + globalNotifications: GlobalNotification[]; + typingUsers: Record; + typingTimers: Record>; + + addGlobalNotification: ( + notification: Omit, + ) => void; + removeGlobalNotification: (notificationId: string) => void; + clearGlobalNotifications: () => void; + + setTypingUsers: (channelKey: string, users: User[]) => void; + addTypingUser: (channelKey: string, user: User) => void; + removeTypingUser: (channelKey: string, username: string) => void; + setTypingTimer: ( + channelKey: string, + username: string, + timer: NodeJS.Timeout, + ) => void; + clearTypingTimer: (channelKey: string, username: string) => void; +} + +export const createNotificationSlice: StateCreator< + NotificationSlice, + [ + ["zustand/devtools", never], + ["zustand/persist", unknown], + ["zustand/immer", never], + ], + [], + NotificationSlice +> = (set) => ({ + globalNotifications: [], + typingUsers: {}, + typingTimers: {}, + + addGlobalNotification: (notification) => + set( + (state) => { + const newNotification: GlobalNotification = { + id: uuidv4(), + ...notification, + timestamp: new Date(), + }; + state.globalNotifications.push(newNotification); + + // Play error sound for FAIL notifications + if (notification.type === "fail") { + try { + const audio = new Audio("/sounds/error.mp3"); + audio.volume = 0.3; + audio.play().catch((error) => { + console.error("Failed to play error sound:", error); + }); + } catch (error) { + console.error("Failed to play error sound:", error); + } + } + }, + false, + "notification/add", + ), + + removeGlobalNotification: (notificationId) => + set( + (state) => { + state.globalNotifications = state.globalNotifications.filter( + (n) => n.id !== notificationId, + ); + }, + false, + "notification/remove", + ), + + clearGlobalNotifications: () => + set( + (state) => { + state.globalNotifications = []; + }, + false, + "notification/clear", + ), + + setTypingUsers: (channelKey, users) => + set( + (state) => { + state.typingUsers[channelKey] = users; + }, + false, + "typing/set", + ), + + addTypingUser: (channelKey, user) => + set( + (state) => { + if (!state.typingUsers[channelKey]) { + state.typingUsers[channelKey] = []; + } + // Don't add if already in the list + if ( + !state.typingUsers[channelKey].some( + (u) => u.username === user.username, + ) + ) { + state.typingUsers[channelKey].push(user); + } + }, + false, + "typing/add", + ), + + removeTypingUser: (channelKey, username) => + set( + (state) => { + if (state.typingUsers[channelKey]) { + state.typingUsers[channelKey] = state.typingUsers[channelKey].filter( + (u) => u.username !== username, + ); + } + }, + false, + "typing/remove", + ), + + setTypingTimer: (channelKey, username, timer) => + set( + (state) => { + if (!state.typingTimers[channelKey]) { + state.typingTimers[channelKey] = {}; + } + state.typingTimers[channelKey][username] = timer; + }, + false, + "typing/timer/set", + ), + + clearTypingTimer: (channelKey, username) => + set( + (state) => { + if (state.typingTimers[channelKey]?.[username]) { + clearTimeout(state.typingTimers[channelKey][username]); + delete state.typingTimers[channelKey][username]; + } + }, + false, + "typing/timer/clear", + ), +}); diff --git a/src/store/slices/privateChatSlice.ts b/src/store/slices/privateChatSlice.ts new file mode 100644 index 00000000..df1547f9 --- /dev/null +++ b/src/store/slices/privateChatSlice.ts @@ -0,0 +1,174 @@ +import { v4 as uuidv4 } from "uuid"; +import type { StateCreator } from "zustand"; +import type { PrivateChat } from "../../types"; + +export interface PrivateChatSlice { + // Get private chats for a server + getPrivateChats: (serverId: string) => PrivateChat[]; + + // Find a private chat by ID + findPrivateChat: ( + serverId: string, + privateChatId: string, + ) => PrivateChat | undefined; + + // Find a private chat by username + findPrivateChatByUsername: ( + serverId: string, + username: string, + ) => PrivateChat | undefined; + + // Create a new private chat + createPrivateChat: ( + serverId: string, + username: string, + initialData?: Partial, + ) => PrivateChat; + + // Update a private chat + updatePrivateChat: ( + serverId: string, + privateChatId: string, + updates: Partial, + ) => void; + + // Delete a private chat + deletePrivateChat: (serverId: string, privateChatId: string) => void; + + // Pin/unpin a private chat + pinPrivateChat: (serverId: string, privateChatId: string) => void; + unpinPrivateChat: (serverId: string, privateChatId: string) => void; + + // Reorder private chats + reorderPrivateChats: (serverId: string, privateChatIds: string[]) => void; + + // Mark as read + markPrivateChatAsRead: (serverId: string, privateChatId: string) => void; + + // Update unread counts + incrementUnreadCount: (serverId: string, privateChatId: string) => void; + setMentioned: ( + serverId: string, + privateChatId: string, + isMentioned: boolean, + ) => void; +} + +export const createPrivateChatSlice: StateCreator< + PrivateChatSlice, + [ + ["zustand/devtools", never], + ["zustand/persist", unknown], + ["zustand/immer", never], + ], + [], + PrivateChatSlice +> = (set, get) => ({ + getPrivateChats: (serverId) => { + // This will need access to servers from serverSlice + // For now, return empty array - will be connected in main store + return []; + }, + + findPrivateChat: (serverId, privateChatId) => { + const privateChats = get().getPrivateChats(serverId); + return privateChats.find((pc) => pc.id === privateChatId); + }, + + findPrivateChatByUsername: (serverId, username) => { + const privateChats = get().getPrivateChats(serverId); + return privateChats.find( + (pc) => pc.username.toLowerCase() === username.toLowerCase(), + ); + }, + + createPrivateChat: (serverId, username, initialData) => { + const newPrivateChat: PrivateChat = { + id: uuidv4(), + username, + serverId, + unreadCount: 0, + isMentioned: false, + lastActivity: new Date(), + isOnline: false, + isAway: false, + ...initialData, + }; + + // Add to server's private chats + // This will be handled by the main store combining slices + return newPrivateChat; + }, + + updatePrivateChat: (serverId, privateChatId, updates) => + set( + (state) => { + // Will be implemented in main store with server slice access + }, + false, + "privateChat/update", + ), + + deletePrivateChat: (serverId, privateChatId) => + set( + (state) => { + // Will be implemented in main store with server slice access + }, + false, + "privateChat/delete", + ), + + pinPrivateChat: (serverId, privateChatId) => + set( + (state) => { + // Will be implemented in main store with server slice access + }, + false, + "privateChat/pin", + ), + + unpinPrivateChat: (serverId, privateChatId) => + set( + (state) => { + // Will be implemented in main store with server slice access + }, + false, + "privateChat/unpin", + ), + + reorderPrivateChats: (serverId, privateChatIds) => + set( + (state) => { + // Will be implemented in main store with server slice access + }, + false, + "privateChat/reorder", + ), + + markPrivateChatAsRead: (serverId, privateChatId) => + set( + (state) => { + // Will be implemented in main store with server slice access + }, + false, + "privateChat/markRead", + ), + + incrementUnreadCount: (serverId, privateChatId) => + set( + (state) => { + // Will be implemented in main store with server slice access + }, + false, + "privateChat/incrementUnread", + ), + + setMentioned: (serverId, privateChatId, isMentioned) => + set( + (state) => { + // Will be implemented in main store with server slice access + }, + false, + "privateChat/setMentioned", + ), +}); diff --git a/src/store/slices/serverSlice.ts b/src/store/slices/serverSlice.ts new file mode 100644 index 00000000..3a5cc5d7 --- /dev/null +++ b/src/store/slices/serverSlice.ts @@ -0,0 +1,550 @@ +import type { StateCreator } from "zustand"; +import type { Channel, PrivateChat, Server, User } from "../../types"; + +export interface ServerSlice { + servers: Server[]; + currentUser: User | null; + isConnecting: boolean; + connectionError: string | null; + hasConnectedToSavedServers: boolean; + + // Server operations + addServer: (server: Server) => void; + updateServer: (serverId: string, updates: Partial) => void; + removeServer: (serverId: string) => void; + getServer: (serverId: string) => Server | undefined; + getServerByHost: (host: string, port: number) => Server | undefined; + + // Connection state + setConnectionState: ( + serverId: string, + connectionState: Server["connectionState"], + ) => void; + setIsConnecting: (isConnecting: boolean) => void; + setConnectionError: (error: string | null) => void; + setHasConnectedToSavedServers: (hasConnected: boolean) => void; + + // Current user + setCurrentUser: (user: User | null) => void; + updateCurrentUser: (updates: Partial) => void; + + // Channel operations + addChannelToServer: (serverId: string, channel: Channel) => void; + removeChannelFromServer: (serverId: string, channelName: string) => void; + updateChannel: ( + serverId: string, + channelId: string, + updates: Partial, + ) => void; + getChannel: (serverId: string, channelId: string) => Channel | undefined; + getChannelByName: ( + serverId: string, + channelName: string, + ) => Channel | undefined; + + // User operations (users in channels) + addUserToChannel: (serverId: string, channelName: string, user: User) => void; + removeUserFromChannel: ( + serverId: string, + channelName: string, + username: string, + ) => void; + updateUserInChannel: ( + serverId: string, + channelName: string, + username: string, + updates: Partial, + ) => void; + getUserInChannel: ( + serverId: string, + channelName: string, + username: string, + ) => User | undefined; + + // Private chat operations + addPrivateChatToServer: (serverId: string, privateChat: PrivateChat) => void; + removePrivateChatFromServer: ( + serverId: string, + privateChatId: string, + ) => void; + updatePrivateChat: ( + serverId: string, + privateChatId: string, + updates: Partial, + ) => void; + getPrivateChat: ( + serverId: string, + privateChatId: string, + ) => PrivateChat | undefined; + getPrivateChatByUsername: ( + serverId: string, + username: string, + ) => PrivateChat | undefined; + + // Server capabilities + setServerCapabilities: (serverId: string, capabilities: string[]) => void; + addServerCapability: (serverId: string, capability: string) => void; + hasServerCapability: (serverId: string, capability: string) => boolean; + + // Server metadata (not IRC metadata, but server object metadata) + updateServerMetadata: ( + serverId: string, + metadata: Record, + ) => void; + + // Batch user operations + setChannelUsers: ( + serverId: string, + channelName: string, + users: User[], + ) => void; + + // Server users (global user list for server) + addServerUser: (serverId: string, user: User) => void; + updateServerUser: ( + serverId: string, + username: string, + updates: Partial, + ) => void; + removeServerUser: (serverId: string, username: string) => void; + getServerUser: (serverId: string, username: string) => User | undefined; +} + +export const createServerSlice: StateCreator< + ServerSlice, + [ + ["zustand/devtools", never], + ["zustand/persist", unknown], + ["zustand/immer", never], + ], + [], + ServerSlice +> = (set, get) => ({ + servers: [], + currentUser: null, + isConnecting: false, + connectionError: null, + hasConnectedToSavedServers: false, + + addServer: (server) => + set( + (state) => { + // Check if server already exists + const exists = state.servers.some((s) => s.id === server.id); + if (!exists) { + // Type assertion to avoid TypeScript's type instantiation depth limit with Immer + const servers = state.servers as unknown as Server[]; + servers.push(server); + } + }, + false, + "server/add", + ), + + updateServer: (serverId, updates) => + set( + (state) => { + const server = state.servers.find((s) => s.id === serverId); + if (server) { + Object.assign(server, updates); + } + }, + false, + "server/update", + ), + + removeServer: (serverId) => + set( + (state) => { + state.servers = state.servers.filter((s) => s.id !== serverId); + }, + false, + "server/remove", + ), + + getServer: (serverId) => { + return get().servers.find((s) => s.id === serverId); + }, + + getServerByHost: (host, port) => { + return get().servers.find((s) => s.host === host && s.port === port); + }, + + setConnectionState: (serverId, connectionState) => + set( + (state) => { + const server = state.servers.find((s) => s.id === serverId); + if (server) { + server.connectionState = connectionState; + server.isConnected = connectionState === "connected"; + } + }, + false, + "server/connectionState", + ), + + setIsConnecting: (isConnecting) => + set( + (state) => { + state.isConnecting = isConnecting; + }, + false, + "server/isConnecting", + ), + + setConnectionError: (error) => + set( + (state) => { + state.connectionError = error; + }, + false, + "server/connectionError", + ), + + setHasConnectedToSavedServers: (hasConnected) => + set( + (state) => { + state.hasConnectedToSavedServers = hasConnected; + }, + false, + "server/hasConnected", + ), + + setCurrentUser: (user) => + set( + (state) => { + state.currentUser = user; + }, + false, + "server/currentUser/set", + ), + + updateCurrentUser: (updates) => + set( + (state) => { + if (state.currentUser) { + Object.assign(state.currentUser, updates); + } + }, + false, + "server/currentUser/update", + ), + + addChannelToServer: (serverId, channel) => + set( + (state) => { + const server = state.servers.find((s) => s.id === serverId); + if (server) { + // Check if channel already exists + const exists = server.channels.some((c) => c.id === channel.id); + if (!exists) { + // Type assertion to avoid TypeScript's type instantiation depth limit with Immer + const channels = server.channels as unknown as Channel[]; + channels.push(channel); + } + } + }, + false, + "server/channel/add", + ), + + removeChannelFromServer: (serverId, channelName) => + set( + (state) => { + const server = state.servers.find((s) => s.id === serverId); + if (server) { + server.channels = server.channels.filter( + (c) => c.name.toLowerCase() !== channelName.toLowerCase(), + ); + } + }, + false, + "server/channel/remove", + ), + + updateChannel: (serverId, channelId, updates) => + set( + (state) => { + const server = state.servers.find((s) => s.id === serverId); + if (server) { + const channel = server.channels.find((c) => c.id === channelId); + if (channel) { + Object.assign(channel, updates); + } + } + }, + false, + "server/channel/update", + ), + + getChannel: (serverId, channelId) => { + const server = get().getServer(serverId); + return server?.channels.find((c) => c.id === channelId); + }, + + getChannelByName: (serverId, channelName) => { + const server = get().getServer(serverId); + return server?.channels.find( + (c) => c.name.toLowerCase() === channelName.toLowerCase(), + ); + }, + + addUserToChannel: (serverId, channelName, user) => + set( + (state) => { + const server = state.servers.find((s) => s.id === serverId); + if (server) { + const channel = server.channels.find( + (c) => c.name.toLowerCase() === channelName.toLowerCase(), + ); + if (channel) { + // Check if user already exists + const exists = channel.users.some( + (u) => u.username === user.username, + ); + if (!exists) { + // Type assertion to avoid TypeScript's type instantiation depth limit with Immer + const users = channel.users as unknown as User[]; + users.push(user); + } + } + } + }, + false, + "server/channel/user/add", + ), + + removeUserFromChannel: (serverId, channelName, username) => + set( + (state) => { + const server = state.servers.find((s) => s.id === serverId); + if (server) { + const channel = server.channels.find( + (c) => c.name.toLowerCase() === channelName.toLowerCase(), + ); + if (channel) { + channel.users = channel.users.filter( + (u) => u.username !== username, + ); + } + } + }, + false, + "server/channel/user/remove", + ), + + updateUserInChannel: (serverId, channelName, username, updates) => + set( + (state) => { + const server = state.servers.find((s) => s.id === serverId); + if (server) { + const channel = server.channels.find( + (c) => c.name.toLowerCase() === channelName.toLowerCase(), + ); + if (channel) { + const user = channel.users.find((u) => u.username === username); + if (user) { + Object.assign(user, updates); + } + } + } + }, + false, + "server/channel/user/update", + ), + + getUserInChannel: (serverId, channelName, username) => { + const server = get().getServer(serverId); + const channel = server?.channels.find( + (c) => c.name.toLowerCase() === channelName.toLowerCase(), + ); + return channel?.users.find((u) => u.username === username); + }, + + addPrivateChatToServer: (serverId, privateChat) => + set( + (state) => { + const server = state.servers.find((s) => s.id === serverId); + if (server) { + if (!server.privateChats) { + server.privateChats = []; + } + // Check if already exists + const exists = server.privateChats.some( + (pc) => pc.id === privateChat.id, + ); + if (!exists) { + // Type assertion to avoid TypeScript's type instantiation depth limit with Immer + const privateChats = + server.privateChats as unknown as PrivateChat[]; + privateChats.push(privateChat); + } + } + }, + false, + "server/privateChat/add", + ), + + removePrivateChatFromServer: (serverId, privateChatId) => + set( + (state) => { + const server = state.servers.find((s) => s.id === serverId); + if (server?.privateChats) { + server.privateChats = server.privateChats.filter( + (pc) => pc.id !== privateChatId, + ); + } + }, + false, + "server/privateChat/remove", + ), + + updatePrivateChat: (serverId, privateChatId, updates) => + set( + (state) => { + const server = state.servers.find((s) => s.id === serverId); + if (server?.privateChats) { + const privateChat = server.privateChats.find( + (pc) => pc.id === privateChatId, + ); + if (privateChat) { + Object.assign(privateChat, updates); + } + } + }, + false, + "server/privateChat/update", + ), + + getPrivateChat: (serverId, privateChatId) => { + const server = get().getServer(serverId); + return server?.privateChats?.find((pc) => pc.id === privateChatId); + }, + + getPrivateChatByUsername: (serverId, username) => { + const server = get().getServer(serverId); + return server?.privateChats?.find( + (pc) => pc.username.toLowerCase() === username.toLowerCase(), + ); + }, + + setServerCapabilities: (serverId, capabilities) => + set( + (state) => { + const server = state.servers.find((s) => s.id === serverId); + if (server) { + server.capabilities = capabilities; + } + }, + false, + "server/capabilities/set", + ), + + addServerCapability: (serverId, capability) => + set( + (state) => { + const server = state.servers.find((s) => s.id === serverId); + if (server) { + if (!server.capabilities) { + server.capabilities = []; + } + if (!server.capabilities.includes(capability)) { + // Type assertion to avoid TypeScript's type instantiation depth limit with Immer + const capabilities = server.capabilities as unknown as string[]; + capabilities.push(capability); + } + } + }, + false, + "server/capabilities/add", + ), + + hasServerCapability: (serverId, capability) => { + const server = get().getServer(serverId); + return server?.capabilities?.includes(capability) || false; + }, + + updateServerMetadata: (serverId, metadata) => + set( + (state) => { + const server = state.servers.find((s) => s.id === serverId); + if (server) { + server.metadata = { + ...server.metadata, + ...metadata, + } as typeof server.metadata; + } + }, + false, + "server/metadata/update", + ), + + setChannelUsers: (serverId, channelName, users) => + set( + (state) => { + const server = state.servers.find((s) => s.id === serverId); + if (server) { + const channel = server.channels.find( + (c) => c.name.toLowerCase() === channelName.toLowerCase(), + ); + if (channel) { + channel.users = users; + } + } + }, + false, + "server/channel/users/set", + ), + + addServerUser: (serverId, user) => + set( + (state) => { + const server = state.servers.find((s) => s.id === serverId); + if (server) { + if (!server.users) { + server.users = []; + } + const exists = server.users.some((u) => u.username === user.username); + if (!exists) { + // Type assertion to avoid TypeScript's type instantiation depth limit with Immer + const users = server.users as unknown as User[]; + users.push(user); + } + } + }, + false, + "server/user/add", + ), + + updateServerUser: (serverId, username, updates) => + set( + (state) => { + const server = state.servers.find((s) => s.id === serverId); + if (server?.users) { + const user = server.users.find((u) => u.username === username); + if (user) { + Object.assign(user, updates); + } + } + }, + false, + "server/user/update", + ), + + removeServerUser: (serverId, username) => + set( + (state) => { + const server = state.servers.find((s) => s.id === serverId); + if (server?.users) { + server.users = server.users.filter((u) => u.username !== username); + } + }, + false, + "server/user/remove", + ), + + getServerUser: (serverId, username) => { + const server = get().getServer(serverId); + return server?.users?.find((u) => u.username === username); + }, +}); diff --git a/src/store/slices/settingsSlice.ts b/src/store/slices/settingsSlice.ts new file mode 100644 index 00000000..bc1afb6e --- /dev/null +++ b/src/store/slices/settingsSlice.ts @@ -0,0 +1,104 @@ +import type { StateCreator } from "zustand"; +import type { GlobalSettings } from "../types"; + +export interface SettingsSlice { + globalSettings: GlobalSettings; + updateGlobalSettings: (settings: Partial) => void; + addToIgnoreList: (pattern: string) => void; + removeFromIgnoreList: (pattern: string) => void; + addCustomMention: (mention: string) => void; + removeCustomMention: (mention: string) => void; +} + +const DEFAULT_SETTINGS: GlobalSettings = { + enableNotifications: false, + notificationSound: "/sounds/notif1.mp3", + enableNotificationSounds: true, + notificationVolume: 0.4, + enableHighlights: true, + sendTypingNotifications: true, + showEvents: true, + showNickChanges: true, + showJoinsParts: true, + showQuits: true, + showKicks: true, + customMentions: [], + ignoreList: ["HistServ!*@*"], + nickname: "", + accountName: "", + accountPassword: "", + enableMultilineInput: true, + multilineOnShiftEnter: true, + autoFallbackToSingleLine: true, + showSafeMedia: true, + showExternalContent: false, + enableMarkdownRendering: false, +}; + +export const createSettingsSlice: StateCreator< + SettingsSlice, + [ + ["zustand/devtools", never], + ["zustand/persist", unknown], + ["zustand/immer", never], + ], + [], + SettingsSlice +> = (set) => ({ + globalSettings: DEFAULT_SETTINGS, + + updateGlobalSettings: (settings) => + set( + (state) => { + state.globalSettings = { ...state.globalSettings, ...settings }; + }, + false, + "settings/update", + ), + + addToIgnoreList: (pattern) => + set( + (state) => { + const trimmed = pattern.trim(); + if (!trimmed || state.globalSettings.ignoreList.includes(trimmed)) { + return; + } + state.globalSettings.ignoreList.push(trimmed); + }, + false, + "settings/ignoreList/add", + ), + + removeFromIgnoreList: (pattern) => + set( + (state) => { + state.globalSettings.ignoreList = + state.globalSettings.ignoreList.filter((p) => p !== pattern); + }, + false, + "settings/ignoreList/remove", + ), + + addCustomMention: (mention) => + set( + (state) => { + const trimmed = mention.trim(); + if (!trimmed || state.globalSettings.customMentions.includes(trimmed)) { + return; + } + state.globalSettings.customMentions.push(trimmed); + }, + false, + "settings/customMentions/add", + ), + + removeCustomMention: (mention) => + set( + (state) => { + state.globalSettings.customMentions = + state.globalSettings.customMentions.filter((m) => m !== mention); + }, + false, + "settings/customMentions/remove", + ), +}); diff --git a/src/store/slices/uiSlice.ts b/src/store/slices/uiSlice.ts new file mode 100644 index 00000000..244806cc --- /dev/null +++ b/src/store/slices/uiSlice.ts @@ -0,0 +1,420 @@ +import type { StateCreator } from "zustand"; +import type { Server } from "../../types"; +import type { Attachment, layoutColumn, UIState } from "../types"; + +export interface UISlice { + ui: UIState; + + // Selection actions + setSelectedServerId: (serverId: string | null) => void; + setPerServerSelection: ( + serverId: string, + selection: { + selectedChannelId: string | null; + selectedPrivateChatId: string | null; + }, + ) => void; + getPerServerSelection: (serverId: string) => { + selectedChannelId: string | null; + selectedPrivateChatId: string | null; + }; + + // Theme actions + toggleDarkMode: () => void; + setDarkMode: (isDark: boolean) => void; + + // Mobile UI actions + toggleMobileMenu: (isOpen?: boolean) => void; + setMobileViewActiveColumn: (column: layoutColumn) => void; + + // Sidebar actions + toggleMemberList: (isVisible?: boolean) => void; + toggleChannelList: (isOpen?: boolean) => void; + toggleServerMenu: (isOpen?: boolean) => void; + + // Context menu actions + showContextMenu: ( + x: number, + y: number, + type: "server" | "channel" | "user" | "message", + itemId: string, + ) => void; + hideContextMenu: () => void; + + // Modal manager actions + openModal: (modalId: string, props?: unknown) => void; + closeModal: (modalId: string) => void; + closeTopModal: () => void; + closeAllModals: () => void; + getModalContext: () => { + serverId: string | null; + channelId: string | null; + selectedServer: Server | undefined; + }; + + // Attachment actions + addInputAttachment: (attachment: Attachment) => void; + removeInputAttachment: (attachmentId: string) => void; + clearInputAttachments: () => void; + + // Server notices popup + toggleServerNoticesPopup: (isOpen?: boolean) => void; + minimizeServerNoticesPopup: (isMinimized?: boolean) => void; + + // Profile view + setProfileViewRequest: (serverId: string, username: string) => void; + clearProfileViewRequest: () => void; + + // Shimmer effects + triggerServerShimmer: (serverId: string) => void; + clearServerShimmer: (serverId: string) => void; + + // Link security warnings + addLinkSecurityWarning: (serverId: string) => void; + removeLinkSecurityWarning: (serverId: string) => void; + + // Prefill server details + setPrefillServerDetails: (details: UIState["prefillServerDetails"]) => void; +} + +const DEFAULT_UI_STATE: UIState = { + selectedServerId: null, + perServerSelections: {}, + isDarkMode: true, + isMobileMenuOpen: false, + isMemberListVisible: true, + isChannelListVisible: true, + mobileViewActiveColumn: "serverList", + isServerMenuOpen: false, + contextMenu: { + isOpen: false, + x: 0, + y: 0, + type: "server", + itemId: null, + }, + prefillServerDetails: null, + inputAttachments: [], + linkSecurityWarnings: [], + isServerNoticesPopupOpen: false, + serverNoticesPopupMinimized: false, + profileViewRequest: null, + serverShimmer: new Set(), + modals: {}, + modalHistory: [], +}; + +export const createUISlice: StateCreator< + UISlice, + [ + ["zustand/devtools", never], + ["zustand/persist", unknown], + ["zustand/immer", never], + ], + [], + UISlice +> = (set, get) => ({ + ui: DEFAULT_UI_STATE, + + setSelectedServerId: (serverId) => + set( + (state) => { + state.ui.selectedServerId = serverId; + }, + false, + "ui/selectServer", + ), + + setPerServerSelection: (serverId, selection) => + set( + (state) => { + state.ui.perServerSelections[serverId] = selection; + }, + false, + "ui/setSelection", + ), + + getPerServerSelection: (serverId) => { + return ( + get().ui.perServerSelections[serverId] || { + selectedChannelId: null, + selectedPrivateChatId: null, + } + ); + }, + + toggleDarkMode: () => + set( + (state) => { + state.ui.isDarkMode = !state.ui.isDarkMode; + }, + false, + "ui/toggleDarkMode", + ), + + setDarkMode: (isDark) => + set( + (state) => { + state.ui.isDarkMode = isDark; + }, + false, + "ui/setDarkMode", + ), + + toggleMobileMenu: (isOpen) => + set( + (state) => { + state.ui.isMobileMenuOpen = isOpen ?? !state.ui.isMobileMenuOpen; + }, + false, + "ui/toggleMobileMenu", + ), + + setMobileViewActiveColumn: (column) => + set( + (state) => { + state.ui.mobileViewActiveColumn = column; + }, + false, + "ui/setMobileColumn", + ), + + toggleMemberList: (isVisible) => + set( + (state) => { + state.ui.isMemberListVisible = + isVisible ?? !state.ui.isMemberListVisible; + }, + false, + "ui/toggleMemberList", + ), + + toggleChannelList: (isOpen) => + set( + (state) => { + state.ui.isChannelListVisible = + isOpen ?? !state.ui.isChannelListVisible; + }, + false, + "ui/toggleChannelList", + ), + + toggleServerMenu: (isOpen) => + set( + (state) => { + state.ui.isServerMenuOpen = isOpen ?? !state.ui.isServerMenuOpen; + }, + false, + "ui/toggleServerMenu", + ), + + showContextMenu: (x, y, type, itemId) => + set( + (state) => { + state.ui.contextMenu = { + isOpen: true, + x, + y, + type, + itemId, + }; + }, + false, + "ui/showContextMenu", + ), + + hideContextMenu: () => + set( + (state) => { + state.ui.contextMenu.isOpen = false; + }, + false, + "ui/hideContextMenu", + ), + + openModal: (modalId, props) => + set( + (state) => { + state.ui.modals[modalId] = { isOpen: true, props }; + if (!state.ui.modalHistory.includes(modalId)) { + state.ui.modalHistory.push(modalId); + } + }, + false, + "ui/openModal", + ), + + closeModal: (modalId) => + set( + (state) => { + if (state.ui.modals[modalId]) { + state.ui.modals[modalId].isOpen = false; + } + state.ui.modalHistory = state.ui.modalHistory.filter( + (id) => id !== modalId, + ); + }, + false, + "ui/closeModal", + ), + + closeTopModal: () => + set( + (state) => { + const topModalId = + state.ui.modalHistory[state.ui.modalHistory.length - 1]; + if (topModalId && state.ui.modals[topModalId]) { + state.ui.modals[topModalId].isOpen = false; + state.ui.modalHistory.pop(); + } + }, + false, + "ui/closeTopModal", + ), + + closeAllModals: () => + set( + (state) => { + for (const modalId of Object.keys(state.ui.modals)) { + state.ui.modals[modalId].isOpen = false; + } + state.ui.modalHistory = []; + }, + false, + "ui/closeAllModals", + ), + + getModalContext: () => { + const state = get(); + const selectedServerId = state.ui.selectedServerId; + const selection = selectedServerId + ? (get() as UISlice).getPerServerSelection(selectedServerId) + : { selectedChannelId: null, selectedPrivateChatId: null }; + + return { + serverId: selectedServerId, + channelId: selection.selectedChannelId, + selectedServer: undefined, // Will be populated by combining with server slice + }; + }, + + addInputAttachment: (attachment) => + set( + (state) => { + state.ui.inputAttachments.push(attachment); + }, + false, + "ui/addAttachment", + ), + + removeInputAttachment: (attachmentId) => + set( + (state) => { + state.ui.inputAttachments = state.ui.inputAttachments.filter( + (a) => a.id !== attachmentId, + ); + }, + false, + "ui/removeAttachment", + ), + + clearInputAttachments: () => + set( + (state) => { + state.ui.inputAttachments = []; + }, + false, + "ui/clearAttachments", + ), + + toggleServerNoticesPopup: (isOpen) => + set( + (state) => { + state.ui.isServerNoticesPopupOpen = + isOpen ?? !state.ui.isServerNoticesPopupOpen; + }, + false, + "ui/toggleServerNotices", + ), + + minimizeServerNoticesPopup: (isMinimized) => + set( + (state) => { + state.ui.serverNoticesPopupMinimized = + isMinimized ?? !state.ui.serverNoticesPopupMinimized; + }, + false, + "ui/minimizeServerNotices", + ), + + setProfileViewRequest: (serverId, username) => + set( + (state) => { + state.ui.profileViewRequest = { serverId, username }; + }, + false, + "ui/setProfileView", + ), + + clearProfileViewRequest: () => + set( + (state) => { + state.ui.profileViewRequest = null; + }, + false, + "ui/clearProfileView", + ), + + triggerServerShimmer: (serverId) => + set( + (state) => { + if (!state.ui.serverShimmer) { + state.ui.serverShimmer = new Set(); + } + state.ui.serverShimmer.add(serverId); + }, + false, + "ui/triggerShimmer", + ), + + clearServerShimmer: (serverId) => + set( + (state) => { + state.ui.serverShimmer?.delete(serverId); + }, + false, + "ui/clearShimmer", + ), + + addLinkSecurityWarning: (serverId) => + set( + (state) => { + state.ui.linkSecurityWarnings.push({ + serverId, + timestamp: Date.now(), + }); + }, + false, + "ui/addLinkWarning", + ), + + removeLinkSecurityWarning: (serverId) => + set( + (state) => { + state.ui.linkSecurityWarnings = state.ui.linkSecurityWarnings.filter( + (w) => w.serverId !== serverId, + ); + }, + false, + "ui/removeLinkWarning", + ), + + setPrefillServerDetails: (details) => + set( + (state) => { + state.ui.prefillServerDetails = details; + }, + false, + "ui/setPrefill", + ), +}); diff --git a/src/store/types.ts b/src/store/types.ts index 338f6a48..9b1dc9e7 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -1,6 +1,7 @@ -type layoutColumn = "serverList" | "chatView" | "memberList"; +// UI Types +export type layoutColumn = "serverList" | "chatView" | "memberList"; -interface ConnectionDetails { +export interface ConnectionDetails { name: string; host: string; port: string; @@ -12,3 +13,203 @@ interface ConnectionDetails { title?: string; }; } + +export interface Attachment { + id: string; + type: "image"; + url: string; + filename: string; +} + +// Batch Event Types +export interface JoinBatchEvent { + type: "JOIN"; + data: { + serverId: string; + username: string; + channelName: string; + account?: string; + realname?: string; + }; +} + +export interface QuitBatchEvent { + type: "QUIT"; + data: { + serverId: string; + username: string; + reason: string; + }; +} + +export interface PartBatchEvent { + type: "PART"; + data: { + serverId: string; + username: string; + channelName: string; + reason?: string; + }; +} + +export type BatchEvent = JoinBatchEvent | QuitBatchEvent | PartBatchEvent; + +export interface BatchInfo { + type: string; + parameters?: string[]; + events: BatchEvent[]; + startTime: Date; +} + +// Storage Types +export type SavedMetadata = Record< + string, + Record> +>; + +export type PinnedPrivateChatsMap = Record< + string, + Array<{ username: string; order: number }> +>; + +export type ChannelOrderMap = Record; + +// Global Settings +export interface GlobalSettings { + enableNotifications: boolean; + notificationSound: string; + enableNotificationSounds: boolean; + notificationVolume: number; + enableHighlights: boolean; + sendTypingNotifications: boolean; + showEvents: boolean; + showNickChanges: boolean; + showJoinsParts: boolean; + showQuits: boolean; + showKicks: boolean; + customMentions: string[]; + ignoreList: string[]; + nickname: string; + accountName: string; + accountPassword: string; + enableMultilineInput: boolean; + multilineOnShiftEnter: boolean; + autoFallbackToSingleLine: boolean; + showSafeMedia: boolean; + showExternalContent: boolean; + enableMarkdownRendering: boolean; +} + +// UI State +export interface UIState { + selectedServerId: string | null; + perServerSelections: Record< + string, + { + selectedChannelId: string | null; + selectedPrivateChatId: string | null; + } + >; + isDarkMode: boolean; + isMobileMenuOpen: boolean; + isMemberListVisible: boolean; + isChannelListVisible: boolean; + mobileViewActiveColumn: layoutColumn; + isServerMenuOpen: boolean; + contextMenu: { + isOpen: boolean; + x: number; + y: number; + type: "server" | "channel" | "user" | "message"; + itemId: string | null; + }; + prefillServerDetails: ConnectionDetails | null; + inputAttachments: Attachment[]; + linkSecurityWarnings: Array<{ serverId: string; timestamp: number }>; + isServerNoticesPopupOpen: boolean; + serverNoticesPopupMinimized: boolean; + profileViewRequest: { serverId: string; username: string } | null; + serverShimmer?: Set; + modals: Record; + modalHistory: string[]; +} + +// Notification Types +export interface GlobalNotification { + id: string; + type: "fail" | "warn" | "note"; + command: string; + code: string; + message: string; + target?: string; + serverId: string; + timestamp: Date; +} + +// Channel List Types +export interface ChannelListEntry { + channel: string; + userCount: number; + topic: string; +} + +export interface ChannelListFilters { + minUsers?: number; + maxUsers?: number; + minCreationTime?: number; + maxCreationTime?: number; + minTopicTime?: number; + maxTopicTime?: number; + mask?: string; + notMask?: string; +} + +export interface ChannelMetadata { + avatar?: string; + displayName?: string; + fetchedAt: number; +} + +// Metadata Types +export interface MetadataBatchMessage { + target: string; + key: string; + visibility: string; + value: string; +} + +export interface MetadataBatch { + type: string; + messages: MetadataBatchMessage[]; +} + +// Account Registration +export interface PendingRegistration { + serverId: string; + account: string; + email: string; + password: string; +} + +// Store slice interfaces will be defined in their respective slice files +// and combined to form the complete AppState +import type { ChannelSlice } from "./slices/channelSlice"; +import type { IRCActionsSlice } from "./slices/ircActionsSlice"; +import type { MessageSlice } from "./slices/messageSlice"; +import type { MetadataSlice } from "./slices/metadataSlice"; +import type { NotificationSlice } from "./slices/notificationSlice"; +import type { PrivateChatSlice } from "./slices/privateChatSlice"; +import type { ServerSlice } from "./slices/serverSlice"; +import type { SettingsSlice } from "./slices/settingsSlice"; +import type { UISlice } from "./slices/uiSlice"; + +// Combined app state type +export type AppState = SettingsSlice & + NotificationSlice & + UISlice & + MessageSlice & + PrivateChatSlice & + ChannelSlice & + MetadataSlice & + ServerSlice & + IRCActionsSlice; diff --git a/src/types/index.ts b/src/types/index.ts index 05702143..7a08c2db 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -46,6 +46,15 @@ export interface Server { jwtToken?: string; // JWT token for filehost authentication isUnrealIRCd?: boolean; // Whether this server is running UnrealIRCd elist?: string; // ELIST ISUPPORT value for extended LIST capabilities + // Configuration properties (for backward compatibility with components) + nickname?: string; + saslAccountName?: string; + saslEnabled?: boolean; + saslPassword?: string; + password?: string; + operUsername?: string; + operPassword?: string; + operOnConnect?: boolean; } export interface ServerConfig { diff --git a/tests/App.test.tsx b/tests/App.test.tsx index 397edc2e..fa99e362 100644 --- a/tests/App.test.tsx +++ b/tests/App.test.tsx @@ -33,11 +33,7 @@ const mockStoreState = { ui: { selectedServerId: null, perServerSelections: {}, - isAddServerModalOpen: false, - isEditServerModalOpen: false, - editServerId: null, - isSettingsModalOpen: false, - isUserProfileModalOpen: false, + modals: {} as Record, isDarkMode: true, linkSecurityWarnings: [], }, @@ -68,14 +64,14 @@ const mockStoreState = { toggleChannelList: vi.fn(), connectToSavedServers: vi.fn(), toggleMemberList: vi.fn(), - toggleAddServerModal: vi.fn((open?: boolean) => { - mockStoreState.ui.isAddServerModalOpen = - open ?? !mockStoreState.ui.isAddServerModalOpen; + openModal: vi.fn((modalId: string, props?: unknown) => { + mockStoreState.ui.modals[modalId] = { isOpen: true, props }; storeVersion++; }), - toggleSettingsModal: vi.fn((open?: boolean) => { - mockStoreState.ui.isSettingsModalOpen = - open ?? !mockStoreState.ui.isSettingsModalOpen; + closeModal: vi.fn((modalId: string) => { + if (mockStoreState.ui.modals[modalId]) { + mockStoreState.ui.modals[modalId].isOpen = false; + } storeVersion++; }), }; @@ -98,8 +94,7 @@ describe("App", () => { beforeEach(() => { // Reset mock state between tests - mockStoreState.ui.isAddServerModalOpen = false; - mockStoreState.ui.isSettingsModalOpen = false; + mockStoreState.ui.modals = {}; }); afterEach(() => { @@ -115,8 +110,8 @@ describe("App", () => { await user.click(screen.getByTestId("server-list-options-button")); await user.click(screen.getByText(/Add Server/i)); - // Check that toggleAddServerModal was called with true - expect(mockStoreState.toggleAddServerModal).toHaveBeenCalledWith(true); + // Check that openModal was called with "addServer" + expect(mockStoreState.openModal).toHaveBeenCalledWith("addServer"); }); it("Can add a new server with valid information", async () => { @@ -140,8 +135,8 @@ describe("App", () => { await user.click(screen.getByTestId("server-list-options-button")); await user.click(screen.getByText(/Add Server/i)); - // Check that toggleAddServerModal was called - expect(mockStoreState.toggleAddServerModal).toHaveBeenCalledWith(true); + // Check that openModal was called + expect(mockStoreState.openModal).toHaveBeenCalledWith("addServer"); }); it("Shows error message when server connection fails", async () => { @@ -157,8 +152,8 @@ describe("App", () => { await user.click(screen.getByTestId("server-list-options-button")); await user.click(screen.getByText(/Add Server/i)); - // Check that toggleAddServerModal was called - expect(mockStoreState.toggleAddServerModal).toHaveBeenCalledWith(true); + // Check that openModal was called + expect(mockStoreState.openModal).toHaveBeenCalledWith("addServer"); }); it("Shows error message when server connection fails", async () => { @@ -174,8 +169,8 @@ describe("App", () => { await user.click(screen.getByTestId("server-list-options-button")); await user.click(screen.getByText(/Add Server/i)); - // Check that toggleAddServerModal was called - expect(mockStoreState.toggleAddServerModal).toHaveBeenCalledWith(true); + // Check that openModal was called + expect(mockStoreState.openModal).toHaveBeenCalledWith("addServer"); }); }); @@ -187,8 +182,8 @@ describe("App", () => { // Open settings await user.click(screen.getByTestId("user-settings-button")); - // Check that toggleUserProfileModal was called - expect(mockStoreState.toggleUserProfileModal).toHaveBeenCalledWith(true); + // Check that openModal was called with "settings" + expect(mockStoreState.openModal).toHaveBeenCalledWith("settings"); }); }); }); diff --git a/tests/components/ChannelListModal.test.tsx b/tests/components/ChannelListModal.test.tsx index 0e35eaad..96cb061e 100644 --- a/tests/components/ChannelListModal.test.tsx +++ b/tests/components/ChannelListModal.test.tsx @@ -1,6 +1,6 @@ -import { fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, test, vi } from "vitest"; import ChannelListModal from "../../src/components/ui/ChannelListModal"; +import { fireEvent, renderWithProviders, screen } from "../test-utils"; // Mock the store vi.mock("../../src/store", () => ({ @@ -15,7 +15,9 @@ vi.mock("../../src/store", () => ({ }, ], ui: { - showChannelListModal: true, + modals: { + channelList: { isOpen: true }, + }, selectedServerId: "server1", }, channelList: { @@ -41,7 +43,8 @@ vi.mock("../../src/store", () => ({ joinChannel: vi.fn(), listChannels: vi.fn(), updateChannelListFilters: vi.fn(), - toggleChannelListModal: vi.fn(), + openModal: vi.fn(), + closeModal: vi.fn(), })), })); @@ -51,7 +54,7 @@ describe("ChannelListModal", () => { }); test("renders channel list modal", () => { - render(); + renderWithProviders(); expect(screen.getByText("Channels on Test Server")).toBeInTheDocument(); expect(screen.getByText("channel1")).toBeInTheDocument(); @@ -60,7 +63,7 @@ describe("ChannelListModal", () => { }); test("displays channel information correctly", () => { - render(); + renderWithProviders(); expect(screen.getByText("10")).toBeInTheDocument(); expect(screen.getByText("20")).toBeInTheDocument(); @@ -71,7 +74,7 @@ describe("ChannelListModal", () => { }); test("filters channels by name", () => { - render(); + renderWithProviders(); const searchInput = screen.getByPlaceholderText("Filter channels..."); fireEvent.change(searchInput, { target: { value: "channel1" } }); @@ -82,7 +85,7 @@ describe("ChannelListModal", () => { }); test("sorts channels by user count", () => { - render(); + renderWithProviders(); const sortSelect = screen.getByRole("combobox"); fireEvent.change(sortSelect, { target: { value: "users" } }); @@ -96,7 +99,7 @@ describe("ChannelListModal", () => { }); test("joins channel when clicked", () => { - render(); + renderWithProviders(); const channelDiv = screen.getByText("channel1").closest("div"); if (channelDiv) { @@ -107,30 +110,30 @@ describe("ChannelListModal", () => { }); test("shows loading state when listing channels", () => { - render(); + renderWithProviders(); // Should show channels by default expect(screen.getByText("channel1")).toBeInTheDocument(); }); test("closes modal when close button is clicked", () => { - render(); + renderWithProviders(); - const closeButton = screen.getByLabelText("Close"); + const closeButton = screen.getByLabelText("Close modal"); fireEvent.click(closeButton); // Modal should be closable }); test("shows empty state when no channels", () => { - render(); + renderWithProviders(); // Should show channels by default expect(screen.getByText("channel1")).toBeInTheDocument(); }); test("shows filtered empty state", () => { - render(); + renderWithProviders(); const searchInput = screen.getByPlaceholderText("Filter channels..."); fireEvent.change(searchInput, { target: { value: "nonexistent" } }); diff --git a/tests/components/ChannelRenameModal.test.tsx b/tests/components/ChannelRenameModal.test.tsx index c5f6719e..aeea4af3 100644 --- a/tests/components/ChannelRenameModal.test.tsx +++ b/tests/components/ChannelRenameModal.test.tsx @@ -1,7 +1,7 @@ -import { fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, test, vi } from "vitest"; import ChannelRenameModal from "../../src/components/ui/ChannelRenameModal"; import useStore from "../../src/store"; +import { fireEvent, renderWithProviders, screen } from "../test-utils"; // Mock the store vi.mock("../../src/store", () => ({ @@ -16,7 +16,9 @@ vi.mock("../../src/store", () => ({ }, ], ui: { - showChannelRenameModal: true, + modals: { + channelRename: { isOpen: true }, + }, selectedServerId: "server1", perServerSelections: { server1: { @@ -27,7 +29,8 @@ vi.mock("../../src/store", () => ({ }, selectedServerId: "server1", renameChannel: vi.fn(), - toggleChannelRenameModal: vi.fn(), + openModal: vi.fn(), + closeModal: vi.fn(), })), })); @@ -37,7 +40,7 @@ describe("ChannelRenameModal", () => { }); test("renders channel rename modal", () => { - render(); + renderWithProviders(); expect( screen.getByRole("heading", { name: "Rename Channel" }), @@ -45,13 +48,13 @@ describe("ChannelRenameModal", () => { }); test("closes modal when cancel button is clicked", () => { - render(); + renderWithProviders(); // There is no cancel button, just close button }); test("closes modal when close button is clicked", () => { - render(); + renderWithProviders(); const closeButtons = screen.getAllByRole("button"); const closeButton = closeButtons.find( @@ -63,7 +66,7 @@ describe("ChannelRenameModal", () => { }); test("renames channel when form is submitted", () => { - render(); + renderWithProviders(); const newNameInput = screen.getByPlaceholderText("Enter new channel name"); const renameButton = screen.getByRole("button", { name: "Rename Channel" }); @@ -73,7 +76,7 @@ describe("ChannelRenameModal", () => { }); test("shows validation error for empty new name", () => { - render(); + renderWithProviders(); const renameButton = screen.getByRole("button", { name: /Rename/ }); fireEvent.click(renameButton); @@ -81,13 +84,34 @@ describe("ChannelRenameModal", () => { test("does not render when modal is closed", () => { vi.mocked(useStore).mockReturnValue({ - servers: [], - ui: { showChannelRenameModal: false }, + servers: [ + { + id: "server1", + name: "Test Server", + host: "irc.example.com", + port: 6667, + channels: [{ id: "channel1", name: "#oldchannel" }], + }, + ], + ui: { + modals: { + channelRename: { isOpen: false }, + }, + selectedServerId: "server1", + perServerSelections: { + server1: { + selectedChannelId: "channel1", + selectedPrivateChatId: null, + }, + }, + }, + selectedServerId: "server1", renameChannel: vi.fn(), - toggleChannelRenameModal: vi.fn(), + openModal: vi.fn(), + closeModal: vi.fn(), }); - const { container } = render(); + const { container } = renderWithProviders(); expect(container.firstChild).toBeNull(); }); }); diff --git a/tests/components/ChatArea.test.tsx b/tests/components/ChatArea.test.tsx index de5bed33..e12a2bbe 100644 --- a/tests/components/ChatArea.test.tsx +++ b/tests/components/ChatArea.test.tsx @@ -71,15 +71,8 @@ describe("ChatArea Tab Completion Integration", () => { }, isMemberListVisible: true, isChannelListVisible: true, - isAddServerModalOpen: false, - isEditServerModalOpen: false, - editServerId: null, - isSettingsModalOpen: false, - isUserProfileModalOpen: false, isDarkMode: true, isMobileMenuOpen: false, - isChannelListModalOpen: false, - isChannelRenameModalOpen: false, linkSecurityWarnings: [], mobileViewActiveColumn: "serverList", isServerMenuOpen: false, @@ -96,6 +89,9 @@ describe("ChatArea Tab Completion Integration", () => { isServerNoticesPopupOpen: false, serverNoticesPopupMinimized: false, profileViewRequest: null, + // Modal manager state + modals: {}, + modalHistory: [], }, messages: {}, typingUsers: {}, diff --git a/tests/components/LinkSecurityWarningModal.test.tsx b/tests/components/LinkSecurityWarningModal.test.tsx index 44852128..f7974121 100644 --- a/tests/components/LinkSecurityWarningModal.test.tsx +++ b/tests/components/LinkSecurityWarningModal.test.tsx @@ -64,6 +64,10 @@ describe("LinkSecurityWarningModal", () => { host: "irc.example.com", port: 6667, linkSecurity: 1, + channels: [], + privateChats: [], + isConnected: false, + users: [], }, { id: "server2", @@ -71,9 +75,12 @@ describe("LinkSecurityWarningModal", () => { host: "localhost", port: 6667, linkSecurity: 0, + channels: [], + privateChats: [], + isConnected: false, + users: [], }, - // biome-ignore lint/suspicious/noExplicitAny: Partial mock doesn't need full Server type - ] as any[], // Cast to any to avoid full Server type + ], ui: { linkSecurityWarnings: [ { serverId: "server1", timestamp: Date.now() }, @@ -91,15 +98,13 @@ describe("LinkSecurityWarningModal", () => { isChannelListVisible: false, isChannelListModalOpen: false, isChannelRenameModalOpen: false, - // biome-ignore lint/suspicious/noExplicitAny: Partial mock type - mobileViewActiveColumn: "chat" as any, + mobileViewActiveColumn: "chat", isServerMenuOpen: false, contextMenu: { isOpen: false, x: 0, y: 0, - // biome-ignore lint/suspicious/noExplicitAny: Partial mock type - type: "server" as any, + type: "server", itemId: null, }, prefillServerDetails: null, @@ -165,6 +170,9 @@ describe("LinkSecurityWarningModal", () => { port: 6667, nickname: "testuser", channels: [], + privateChats: [], + isConnected: false, + users: [], saslEnabled: false, }, { @@ -174,6 +182,9 @@ describe("LinkSecurityWarningModal", () => { port: 6667, nickname: "testuser", channels: [], + privateChats: [], + isConnected: false, + users: [], saslEnabled: false, }, ]); @@ -297,19 +308,27 @@ describe("LinkSecurityWarningModal", () => { const mockSavedServers = [ { id: "server1", + name: "Test Server", host: "irc.example.com", port: 6667, nickname: "testuser", - saslEnabled: false, channels: [], + privateChats: [], + isConnected: false, + users: [], + saslEnabled: false, }, { id: "server2", + name: "Local Server", host: "localhost", port: 6667, nickname: "testuser", - saslEnabled: false, channels: [], + privateChats: [], + isConnected: false, + users: [], + saslEnabled: false, }, ]; @@ -368,9 +387,12 @@ describe("LinkSecurityWarningModal", () => { // Verify saveServersToLocalStorage was called with updated server config expect(mockSaveServersToLocalStorage).toHaveBeenCalled(); - const savedServers = mockSaveServersToLocalStorage.mock.calls[0][0]; - // biome-ignore lint/suspicious/noExplicitAny: Mock server config doesn't need full typing - const updatedServer = savedServers.find((s: any) => s.id === "server1"); + const savedServers = mockSaveServersToLocalStorage.mock + .calls[0][0] as Array<{ + id: string; + skipLinkSecurityWarning?: boolean; + }>; + const updatedServer = savedServers.find((s) => s.id === "server1"); expect(updatedServer?.skipLinkSecurityWarning).toBe(true); } diff --git a/tests/components/MetadataDisplay.test.tsx b/tests/components/MetadataDisplay.test.tsx index 7e00ea49..76224911 100644 --- a/tests/components/MetadataDisplay.test.tsx +++ b/tests/components/MetadataDisplay.test.tsx @@ -133,15 +133,8 @@ describe("Metadata Display Features", () => { }, isMemberListVisible: true, isChannelListVisible: true, - isAddServerModalOpen: false, - isEditServerModalOpen: false, - editServerId: null, - isSettingsModalOpen: false, - isUserProfileModalOpen: false, isDarkMode: true, isMobileMenuOpen: false, - isChannelListModalOpen: false, - isChannelRenameModalOpen: false, linkSecurityWarnings: [], mobileViewActiveColumn: "serverList", isServerMenuOpen: false, @@ -158,6 +151,9 @@ describe("Metadata Display Features", () => { isServerNoticesPopupOpen: false, serverNoticesPopupMinimized: false, profileViewRequest: null, + // Modal manager state + modals: {}, + modalHistory: [], }, messages: { "server1-channel1": mockChannel.messages, diff --git a/tests/components/ModerationModal.test.tsx b/tests/components/ModerationModal.test.tsx index 86b2fd89..f04fc5cc 100644 --- a/tests/components/ModerationModal.test.tsx +++ b/tests/components/ModerationModal.test.tsx @@ -1,7 +1,8 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, screen } from "@testing-library/react"; import { beforeEach, describe, expect, test, vi } from "vitest"; import type { ModerationAction } from "../../src/components/ui/ModerationModal"; import ModerationModal from "../../src/components/ui/ModerationModal"; +import { renderWithProviders } from "../test-utils"; describe("ModerationModal", () => { const mockOnClose = vi.fn(); @@ -12,7 +13,7 @@ describe("ModerationModal", () => { }); const renderModal = (action: ModerationAction = "warn", isOpen = true) => { - return render( + return renderWithProviders( { test("calls onClose when close button is clicked", () => { renderModal("warn"); - const closeButton = screen.getByRole("button", { name: "" }); // Close button has no accessible name + const closeButton = screen.getByRole("button", { name: "Close modal" }); fireEvent.click(closeButton); expect(mockOnClose).toHaveBeenCalledTimes(1); @@ -148,7 +149,7 @@ describe("ModerationModal", () => { fireEvent.change(reasonInput, { target: { value: "Test reason" } }); // Close modal - const closeButton = screen.getByRole("button", { name: "" }); // Close button has no accessible name + const closeButton = screen.getByRole("button", { name: "Close modal" }); fireEvent.click(closeButton); expect(mockOnClose).toHaveBeenCalledTimes(1); diff --git a/tests/components/UserSettings.test.tsx b/tests/components/UserSettings.test.tsx index c97e6c0d..77a9f17f 100644 --- a/tests/components/UserSettings.test.tsx +++ b/tests/components/UserSettings.test.tsx @@ -1,6 +1,6 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import UserSettings from "../../src/components/ui/UserSettings"; +import { fireEvent, renderWithProviders, screen, waitFor } from "../test-utils"; // Extend window interface for test environment declare global { @@ -9,64 +9,76 @@ declare global { } } -// Mock the store -vi.mock("../../src/store", () => ({ - default: vi.fn(() => ({ - toggleUserProfileModal: vi.fn(), - servers: [ - { - id: "server1", - name: "Test Server", - host: "irc.example.com", - port: 6667, - capabilities: ["draft/metadata"], - channels: [ - { - id: "channel1", - name: "#test", - users: [ - { - id: "user1", - username: "testuser", - metadata: { - avatar: { value: "avatar-url" }, - "display-name": { value: "Display Name" }, - homepage: { value: "https://example.com" }, - status: { value: "Available" }, - color: { value: "#800040" }, - bot: { value: "" }, - }, +// Create a stable mock store object +const mockStoreState = { + toggleUserProfileModal: vi.fn(), + servers: [ + { + id: "server1", + name: "Test Server", + host: "irc.example.com", + port: 6667, + capabilities: ["draft/metadata"], + channels: [ + { + id: "channel1", + name: "#test", + users: [ + { + id: "user1", + username: "testuser", + metadata: { + avatar: { value: "avatar-url" }, + "display-name": { value: "Display Name" }, + homepage: { value: "https://example.com" }, + status: { value: "Available" }, + color: { value: "#800040" }, + bot: { value: "" }, }, - ], - }, - ], - }, - ], - ui: { - selectedServerId: "server1", - isSettingsModalOpen: true, + }, + ], + }, + ], }, - globalSettings: { - enableNotificationSounds: true, - notificationSound: "/sounds/notif1.mp3", - notificationVolume: 0.8, - enableHighlights: true, - sendTypingNotifications: true, - nickname: "", - accountName: "", - accountPassword: "", - customMentions: [], - showEvents: true, - showNickChanges: true, - showJoinsParts: true, - showQuits: true, + ], + ui: { + modals: { + settings: { isOpen: true }, }, - updateGlobalSettings: vi.fn(), - metadataSet: vi.fn(), - sendRaw: vi.fn(), - setName: vi.fn(), - changeNick: vi.fn(), - })), + selectedServerId: "server1", + }, + globalSettings: { + enableNotificationSounds: true, + notificationSound: "/sounds/notif1.mp3", + notificationVolume: 0.8, + enableHighlights: true, + sendTypingNotifications: true, + nickname: "", + accountName: "", + accountPassword: "", + customMentions: [], + showEvents: true, + showNickChanges: true, + showJoinsParts: true, + showQuits: true, + }, + updateGlobalSettings: vi.fn(), + metadataSet: vi.fn(), + sendRaw: vi.fn(), + setName: vi.fn(), + changeNick: vi.fn(), + openModal: vi.fn(), + closeModal: vi.fn(), + addToIgnoreList: vi.fn(), + removeFromIgnoreList: vi.fn(), + setProfileViewRequest: vi.fn(), + isConnecting: false, + updateServer: vi.fn(), +}; + +// Mock the store - return the same object every time +vi.mock("../../src/store", () => ({ + default: () => mockStoreState, serverSupportsMetadata: vi.fn(() => true), loadSavedServers: vi.fn(() => [ { @@ -115,12 +127,12 @@ describe("UserSettings", () => { }); it("renders the settings modal", () => { - render(); + renderWithProviders(); expect(screen.getByText("User Settings")).toBeInTheDocument(); }); it("displays notification settings with correct text", async () => { - render(); + renderWithProviders(); // Click on the Notifications tab first fireEvent.click(screen.getByText("Notifications")); @@ -140,7 +152,7 @@ describe("UserSettings", () => { // Set the environment variable BEFORE rendering to ensure Account tab is visible window.__HIDE_SERVER_LIST__ = true; // Note: true means hosted chat mode, which shows Account tab - render(); + renderWithProviders(); // Click on the Account tab first fireEvent.click(screen.getByText("Account")); diff --git a/tests/lib/banByHostmask.test.ts b/tests/lib/banByHostmask.test.ts index 0114493c..23cda36b 100644 --- a/tests/lib/banByHostmask.test.ts +++ b/tests/lib/banByHostmask.test.ts @@ -31,7 +31,6 @@ describe("Ban by hostmask functionality", () => { // Reset the store state before each test useStore.setState({ servers: [], - selectedServerId: null, messages: {}, }); vi.clearAllMocks(); diff --git a/tests/lib/nicknameRetry.test.ts b/tests/lib/nicknameRetry.test.ts index 89fdecb8..3473df19 100644 --- a/tests/lib/nicknameRetry.test.ts +++ b/tests/lib/nicknameRetry.test.ts @@ -40,17 +40,10 @@ describe("Nickname retry functionality", () => { selectedPrivateChatId: null, }, }, - isAddServerModalOpen: false, - isEditServerModalOpen: false, - editServerId: null, - isSettingsModalOpen: false, - isUserProfileModalOpen: false, isDarkMode: false, isMobileMenuOpen: false, isMemberListVisible: true, isChannelListVisible: true, - isChannelListModalOpen: false, - isChannelRenameModalOpen: false, linkSecurityWarnings: [], mobileViewActiveColumn: "chatView", isServerMenuOpen: false, @@ -67,6 +60,9 @@ describe("Nickname retry functionality", () => { isServerNoticesPopupOpen: false, serverNoticesPopupMinimized: false, profileViewRequest: null, + // Modal manager state + modals: {}, + modalHistory: [], }, addGlobalNotification: vi.fn(), }; @@ -134,17 +130,10 @@ describe("Nickname retry functionality", () => { selectedPrivateChatId: null, }, }, - isAddServerModalOpen: false, - isEditServerModalOpen: false, - editServerId: null, - isSettingsModalOpen: false, - isUserProfileModalOpen: false, isDarkMode: false, isMobileMenuOpen: false, isMemberListVisible: true, isChannelListVisible: true, - isChannelListModalOpen: false, - isChannelRenameModalOpen: false, linkSecurityWarnings: [], mobileViewActiveColumn: "chatView", isServerMenuOpen: false, @@ -161,6 +150,9 @@ describe("Nickname retry functionality", () => { isServerNoticesPopupOpen: false, serverNoticesPopupMinimized: false, profileViewRequest: null, + // Modal manager state + modals: {}, + modalHistory: [], }, addGlobalNotification: vi.fn(), }; diff --git a/tests/protocol/mode.test.ts b/tests/protocol/mode.test.ts index 0bb9b9aa..5af52609 100644 --- a/tests/protocol/mode.test.ts +++ b/tests/protocol/mode.test.ts @@ -16,7 +16,6 @@ describe("MODE Protocol Handler", () => { servers: [], currentUser: null, isConnecting: false, - selectedServerId: null, connectionError: null, messages: {}, typingUsers: {}, @@ -25,17 +24,10 @@ describe("MODE Protocol Handler", () => { ui: { selectedServerId: null, perServerSelections: {}, - isAddServerModalOpen: false, - isEditServerModalOpen: false, - editServerId: null, - isSettingsModalOpen: false, - isUserProfileModalOpen: false, isDarkMode: true, isMobileMenuOpen: false, isMemberListVisible: true, isChannelListVisible: true, - isChannelListModalOpen: false, - isChannelRenameModalOpen: false, linkSecurityWarnings: [], mobileViewActiveColumn: "serverList", isServerMenuOpen: false, @@ -52,6 +44,9 @@ describe("MODE Protocol Handler", () => { isServerNoticesPopupOpen: false, serverNoticesPopupMinimized: false, profileViewRequest: null, + // Modal manager state + modals: {}, + modalHistory: [], }, }); vi.clearAllMocks(); @@ -59,7 +54,10 @@ describe("MODE Protocol Handler", () => { describe("registerModeHandler", () => { test("should register MODE event handler", () => { - registerModeHandler(mockIRCClient as unknown as IRCClient, useStore); + registerModeHandler( + mockIRCClient as unknown as IRCClient, + useStore as Parameters[1], + ); expect(mockIRCClient.on).toHaveBeenCalledWith( "MODE", @@ -70,7 +68,10 @@ describe("MODE Protocol Handler", () => { describe("MODE event handling", () => { test("should handle channel mode changes with op", () => { - registerModeHandler(mockIRCClient as unknown as IRCClient, useStore); + registerModeHandler( + mockIRCClient as unknown as IRCClient, + useStore as Parameters[1], + ); // Get the MODE handler function const modeCall = mockIRCClient.on.mock.calls.find( @@ -147,7 +148,10 @@ describe("MODE Protocol Handler", () => { }); test("should handle channel mode changes with voice", () => { - registerModeHandler(mockIRCClient as unknown as IRCClient, useStore); + registerModeHandler( + mockIRCClient as unknown as IRCClient, + useStore as Parameters[1], + ); const modeCall = mockIRCClient.on.mock.calls.find( (call) => call[0] === "MODE", @@ -206,7 +210,10 @@ describe("MODE Protocol Handler", () => { }); test("should handle mode removal", () => { - registerModeHandler(mockIRCClient as unknown as IRCClient, useStore); + registerModeHandler( + mockIRCClient as unknown as IRCClient, + useStore as Parameters[1], + ); const modeCall = mockIRCClient.on.mock.calls.find( (call) => call[0] === "MODE", @@ -265,7 +272,10 @@ describe("MODE Protocol Handler", () => { }); test("should handle multiple modes in one command", () => { - registerModeHandler(mockIRCClient as unknown as IRCClient, useStore); + registerModeHandler( + mockIRCClient as unknown as IRCClient, + useStore as Parameters[1], + ); const modeCall = mockIRCClient.on.mock.calls.find( (call) => call[0] === "MODE", @@ -334,7 +344,10 @@ describe("MODE Protocol Handler", () => { }); test("should handle custom prefix configurations", () => { - registerModeHandler(mockIRCClient as unknown as IRCClient, useStore); + registerModeHandler( + mockIRCClient as unknown as IRCClient, + useStore as Parameters[1], + ); const modeCall = mockIRCClient.on.mock.calls.find( (call) => call[0] === "MODE", @@ -393,7 +406,10 @@ describe("MODE Protocol Handler", () => { }); test("should handle multiple prefixes on same user", () => { - registerModeHandler(mockIRCClient as unknown as IRCClient, useStore); + registerModeHandler( + mockIRCClient as unknown as IRCClient, + useStore as Parameters[1], + ); const modeCall = mockIRCClient.on.mock.calls.find( (call) => call[0] === "MODE", @@ -454,7 +470,10 @@ describe("MODE Protocol Handler", () => { }); test("should ignore MODE events for non-existent servers", () => { - registerModeHandler(mockIRCClient as unknown as IRCClient, useStore); + registerModeHandler( + mockIRCClient as unknown as IRCClient, + useStore as Parameters[1], + ); const modeCall = mockIRCClient.on.mock.calls.find( (call) => call[0] === "MODE", @@ -476,7 +495,10 @@ describe("MODE Protocol Handler", () => { }); test("should ignore MODE events for non-existent channels", () => { - registerModeHandler(mockIRCClient as unknown as IRCClient, useStore); + registerModeHandler( + mockIRCClient as unknown as IRCClient, + useStore as Parameters[1], + ); const modeCall = mockIRCClient.on.mock.calls.find( (call) => call[0] === "MODE", @@ -512,7 +534,10 @@ describe("MODE Protocol Handler", () => { }); test("should ignore MODE events when server has no prefix configured", () => { - registerModeHandler(mockIRCClient as unknown as IRCClient, useStore); + registerModeHandler( + mockIRCClient as unknown as IRCClient, + useStore as Parameters[1], + ); const modeCall = mockIRCClient.on.mock.calls.find( (call) => call[0] === "MODE", diff --git a/tests/store/index.test.ts b/tests/store/index.test.ts index 5d25cda7..b2eb99bd 100644 --- a/tests/store/index.test.ts +++ b/tests/store/index.test.ts @@ -15,7 +15,6 @@ describe("Store", () => { expect(state.servers).toEqual([]); expect(state.currentUser).toBeNull(); expect(state.isConnecting).toBe(false); - expect(state.selectedServerId).toBeNull(); expect(state.connectionError).toBeNull(); expect(state.messages).toEqual({}); expect(state.typingUsers).toEqual({}); @@ -25,24 +24,24 @@ describe("Store", () => { }); describe("UI actions", () => { - test("should toggle channel list modal", () => { - const { toggleChannelListModal } = useStore.getState(); + test("should open and close channel list modal", () => { + const { openModal, closeModal } = useStore.getState(); - toggleChannelListModal(true); - expect(useStore.getState().ui.isChannelListModalOpen).toBe(true); + openModal("channelList"); + expect(useStore.getState().ui.modals.channelList?.isOpen).toBe(true); - toggleChannelListModal(false); - expect(useStore.getState().ui.isChannelListModalOpen).toBe(false); + closeModal("channelList"); + expect(useStore.getState().ui.modals.channelList?.isOpen).toBe(false); }); - test("should toggle channel rename modal", () => { - const { toggleChannelRenameModal } = useStore.getState(); + test("should open and close channel rename modal", () => { + const { openModal, closeModal } = useStore.getState(); - toggleChannelRenameModal(true); - expect(useStore.getState().ui.isChannelRenameModalOpen).toBe(true); + openModal("channelRename"); + expect(useStore.getState().ui.modals.channelRename?.isOpen).toBe(true); - toggleChannelRenameModal(false); - expect(useStore.getState().ui.isChannelRenameModalOpen).toBe(false); + closeModal("channelRename"); + expect(useStore.getState().ui.modals.channelRename?.isOpen).toBe(false); }); }); diff --git a/tests/test-utils.tsx b/tests/test-utils.tsx new file mode 100644 index 00000000..2207ad8f --- /dev/null +++ b/tests/test-utils.tsx @@ -0,0 +1,13 @@ +import { render } from "@testing-library/react"; +import type { ReactElement } from "react"; +import { ModalStackProvider } from "../src/components/modals"; + +/** + * Custom render function that wraps components with necessary providers + */ +export function renderWithProviders(ui: ReactElement) { + return render({ui}); +} + +// Re-export everything from @testing-library/react +export * from "@testing-library/react";