diff --git a/ANDROID_KEYBOARD_FIX.md b/ANDROID_KEYBOARD_FIX.md new file mode 100644 index 00000000..c4bd38b0 --- /dev/null +++ b/ANDROID_KEYBOARD_FIX.md @@ -0,0 +1,81 @@ +# Android Keyboard Resize Fix + +## Problem +On Android devices using the Tauri app, when clicking on the channel input box, the keyboard opens but covers the bottom of the screen (including the input box). The viewport only properly resizes after navigating away from the app and returning. + +## Root Cause +The issue was caused by improper Android keyboard handling configuration: +1. Missing `android:windowSoftInputMode` in the AndroidManifest.xml +2. No viewport resize handling for keyboard events +3. Lack of proper mobile CSS for keyboard state transitions + +## Solution Implemented + +### 1. AndroidManifest.xml Configuration +**File:** `src-tauri/gen/android/app/src/main/AndroidManifest.xml` +- Added `android:windowSoftInputMode="adjustResize"` to the MainActivity declaration +- This tells Android to resize the viewport when the keyboard appears instead of covering content + +### 2. Enhanced HTML Viewport Settings +**File:** `index.html` +- Updated viewport meta tag to include `viewport-fit=cover, user-scalable=no` +- Provides better mobile viewport handling + +### 3. Native Android Keyboard Detection +**File:** `src-tauri/gen/android/app/src/main/java/com/obsidianirc/dev/MainActivity.kt` +- Added `setupKeyboardDetection()` method that monitors layout changes +- Detects keyboard open/close events and dispatches JavaScript events +- Provides immediate feedback to the web view when keyboard state changes + +### 4. JavaScript Keyboard Handling Hook +**File:** `src/hooks/useKeyboardResize.ts` (NEW) +- Created a React hook that handles keyboard visibility events +- Listens for both Visual Viewport API changes and native Android events +- Updates CSS custom properties to track keyboard height +- Triggers layout recalculations when keyboard state changes + +### 5. Mobile-Optimized CSS +**File:** `src/index.css` +- Added `--keyboard-height` CSS custom property +- Added mobile-specific CSS rules for keyboard handling +- Ensures proper viewport adjustments with smooth transitions +- Fixed viewport on mobile devices to prevent layout shifts + +### 6. App Integration +**File:** `src/App.tsx` +- Integrated the `useKeyboardResize` hook into the main App component +- Ensures keyboard handling is active throughout the application lifecycle + +## Technical Details + +### Android Window Soft Input Modes +- `adjustResize`: Resizes the window to make room for the keyboard +- This is preferred over `adjustPan` which just shifts content up + +### Visual Viewport API +- Modern browsers provide this API to detect viewport changes +- Especially useful for keyboard events on mobile devices +- Fallback handling for older browsers included + +### CSS Custom Properties +- `--keyboard-height` tracks the current keyboard height +- Allows responsive layout adjustments based on keyboard state +- Smooth transitions prevent jarring layout changes + +## Expected Behavior After Fix +1. User taps on the channel input box +2. Keyboard opens immediately +3. Viewport resizes instantly to accommodate keyboard +4. Input box remains visible above the keyboard +5. No need to navigate away and back to see proper layout + +## Testing Considerations +- Test on various Android devices and screen sizes +- Verify both portrait and landscape orientations +- Ensure keyboard animations are smooth +- Check that all input fields throughout the app behave consistently + +## Browser Compatibility +- Modern Android browsers with Visual Viewport API support +- Fallback handling for older browsers +- iOS support included for future compatibility \ No newline at end of file diff --git a/index.html b/index.html index ca34f712..bad90a49 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - + diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml index 97879757..5ded409a 100644 --- a/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -16,7 +16,8 @@ android:launchMode="singleTask" android:label="@string/main_activity_title" android:name=".MainActivity" - android:exported="true"> + android:exported="true" + android:windowSoftInputMode="adjustResize"> diff --git a/src-tauri/gen/android/app/src/main/java/com/obsidianirc/dev/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/com/obsidianirc/dev/MainActivity.kt index f7111d7c..fe688643 100644 --- a/src-tauri/gen/android/app/src/main/java/com/obsidianirc/dev/MainActivity.kt +++ b/src-tauri/gen/android/app/src/main/java/com/obsidianirc/dev/MainActivity.kt @@ -2,13 +2,43 @@ package com.obsidianirc.dev import android.webkit.WebView import android.annotation.SuppressLint +import android.view.ViewTreeObserver +import android.view.View +import android.graphics.Rect class MainActivity : TauriActivity() { private lateinit var wv: WebView + private var isKeyboardOpen = false override fun onWebViewCreate(webView: WebView) { wv = webView + setupKeyboardDetection() + } + + private fun setupKeyboardDetection() { + val rootView = findViewById(android.R.id.content) + val globalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener { + val rect = Rect() + rootView.getWindowVisibleDisplayFrame(rect) + val screenHeight = rootView.rootView.height + val keypadHeight = screenHeight - rect.bottom + + if (keypadHeight > screenHeight * 0.15) { // keyboard is opened + if (!isKeyboardOpen) { + isKeyboardOpen = true + // Force immediate layout adjustment + wv.evaluateJavascript("window.dispatchEvent(new Event('keyboardDidShow'));", null) + } + } else { // keyboard is closed + if (isKeyboardOpen) { + isKeyboardOpen = false + wv.evaluateJavascript("window.dispatchEvent(new Event('keyboardDidHide'));", null) + } + } + } + + rootView.viewTreeObserver.addOnGlobalLayoutListener(globalLayoutListener) } @SuppressLint("MissingSuperCall", "SetTextI18n") diff --git a/src/App.tsx b/src/App.tsx index 1319f520..c72d454d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import AddServerModal from "./components/ui/AddServerModal"; import ChannelListModal from "./components/ui/ChannelListModal"; import ChannelRenameModal from "./components/ui/ChannelRenameModal"; import UserSettings from "./components/ui/UserSettings"; +import { useKeyboardResize } from "./hooks/useKeyboardResize"; import ircClient from "./lib/ircClient"; import useStore, { loadSavedServers } from "./store"; @@ -77,6 +78,10 @@ const App: React.FC = () => { joinChannel, connectToSavedServers, } = useStore(); + + // Initialize keyboard resize handling for mobile platforms + useKeyboardResize(); + // askPermissions(); useEffect(() => { initializeEnvSettings(toggleAddServerModal, joinChannel); diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index 146249cd..5294194c 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -69,7 +69,15 @@ export const AppLayout: React.FC = () => { return ( <> {__HIDE_SERVER_LIST__ ? null : ( -
+
)} @@ -161,7 +169,8 @@ export const AppLayout: React.FC = () => { }, [isTooNarrowForMemberList, toggleMemberList, isNarrowView]); const getLayoutColumn = (column: layoutColumn) => { - if (isNarrowView && column !== mobileViewActiveColumn) return; + // On mobile, only show the active column + if (isNarrowView && column !== mobileViewActiveColumn) return null; return getLayoutColumnElement(column); }; diff --git a/src/components/layout/ChannelList.tsx b/src/components/layout/ChannelList.tsx index 2a87ce6a..888d204b 100644 --- a/src/components/layout/ChannelList.tsx +++ b/src/components/layout/ChannelList.tsx @@ -1,5 +1,5 @@ import type * as React from "react"; -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { FaChevronDown, FaChevronLeft, @@ -13,6 +13,7 @@ import { FaVolumeUp, } from "react-icons/fa"; import { useMediaQuery } from "../../hooks/useMediaQuery"; +import ircClient from "../../lib/ircClient"; import useStore from "../../store"; import TouchableContextMenu from "../mobile/TouchableContextMenu"; import AddPrivateChatModal from "../ui/AddPrivateChatModal"; @@ -22,6 +23,7 @@ export const ChannelList: React.FC<{ }> = ({ onToggle }: { onToggle: () => void }) => { const { servers, + currentUser: globalCurrentUser, ui: { selectedServerId, selectedChannelId, selectedPrivateChatId }, selectChannel, selectPrivateChat, @@ -29,20 +31,72 @@ export const ChannelList: React.FC<{ leaveChannel, deletePrivateChat, toggleUserProfileModal, - currentUser, + setMobileViewActiveColumn, } = useStore(); + // Get the current user for the selected server from the store data (includes metadata) + const currentUser = useMemo(() => { + if (!selectedServerId) return null; + + // Get the current user's username from IRCClient + const ircCurrentUser = ircClient.getCurrentUser(selectedServerId); + if (!ircCurrentUser) return null; + + // First, check if we have a global current user with metadata for this username + if ( + globalCurrentUser && + globalCurrentUser.username === ircCurrentUser.username + ) { + return globalCurrentUser; + } + + // Find the current user in the server's channel data to get metadata + const selectedServer = servers.find((s) => s.id === selectedServerId); + if (!selectedServer) return ircCurrentUser; + + // Look for the user in any channel to get their metadata + for (const channel of selectedServer.channels) { + const userWithMetadata = channel.users.find( + (u) => u.username === ircCurrentUser.username, + ); + if (userWithMetadata) { + return userWithMetadata; + } + } + + // If not found in channels, return the basic IRC user + return ircCurrentUser; + }, [selectedServerId, servers, globalCurrentUser]); + const [isTextChannelsOpen, setIsTextChannelsOpen] = useState(true); const [isVoiceChannelsOpen, setIsVoiceChannelsOpen] = useState(true); const [isPrivateChatsOpen, setIsPrivateChatsOpen] = useState(true); const [newChannelName, setNewChannelName] = useState(""); const [isAddPrivateChatModalOpen, setIsAddPrivateChatModalOpen] = useState(false); + const [avatarLoadFailed, setAvatarLoadFailed] = useState(false); const selectedServer = servers.find( (server) => server.id === selectedServerId, ); + // Reset avatar load failed state when user or server changes + // biome-ignore lint/correctness/useExhaustiveDependencies: We want to reset when user/server changes + useEffect(() => { + setAvatarLoadFailed(false); + }, [currentUser?.username, selectedServerId]); + + // Get user status based on server connection and away status + const userStatus = useMemo(() => { + if (!selectedServer || !selectedServer.isConnected) { + return "offline"; + } + if (selectedServer.isAway) { + return "away"; + } + return "online"; + }, [selectedServer]); + const handleAddChannel = () => { if (selectedServerId && newChannelName.trim()) { const channelName = newChannelName.trim().startsWith("#") @@ -62,6 +116,16 @@ export const ChannelList: React.FC<{ const isNarrowView = useMediaQuery(); + const handleCollapseClick = () => { + if (isNarrowView) { + // On mobile, navigate to chat view + setMobileViewActiveColumn("chatView"); + } else { + // On desktop, toggle the channel list + onToggle(); + } + }; + return (
{/* Server header */} @@ -69,14 +133,12 @@ export const ChannelList: React.FC<{

{selectedServer?.name || "Home"}

- {!isNarrowView && ( - - )} +
{/* Channel list */} @@ -364,20 +426,13 @@ export const ChannelList: React.FC<{
- {currentUser?.metadata?.avatar?.value ? ( + {currentUser?.metadata?.avatar?.value && !avatarLoadFailed ? ( {currentUser.username} { - // Fallback to initial if image fails to load - e.currentTarget.style.display = "none"; - const parent = e.currentTarget.parentElement; - if (parent && currentUser?.username) { - parent.textContent = currentUser.username - .charAt(0) - .toUpperCase(); - } + onError={() => { + setAvatarLoadFailed(true); }} /> ) : ( @@ -387,7 +442,7 @@ export const ChannelList: React.FC<{ )}
@@ -395,13 +450,11 @@ export const ChannelList: React.FC<{ {currentUser?.username || "User"}
- {currentUser?.status === "online" + {userStatus === "online" ? "Online" - : currentUser?.status === "idle" - ? "Idle" - : currentUser?.status === "dnd" - ? "Do Not Disturb" - : "Offline"} + : userStatus === "away" + ? selectedServer?.awayMessage || "Away" + : "Offline"}
diff --git a/src/components/layout/ChatArea.tsx b/src/components/layout/ChatArea.tsx index 6e339d94..36cdeb88 100644 --- a/src/components/layout/ChatArea.tsx +++ b/src/components/layout/ChatArea.tsx @@ -2,7 +2,7 @@ import { UsersIcon } from "@heroicons/react/24/solid"; import { platform } from "@tauri-apps/plugin-os"; import EmojiPicker, { type EmojiClickData, Theme } from "emoji-picker-react"; import type * as React from "react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { FaArrowDown, @@ -24,6 +24,7 @@ import { v4 as uuidv4 } from "uuid"; import { useEmojiCompletion } from "../../hooks/useEmojiCompletion"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import { useTabCompletion } from "../../hooks/useTabCompletion"; +import { groupConsecutiveEvents } from "../../lib/eventGrouping"; import ircClient from "../../lib/ircClient"; import { parseIrcUrl } from "../../lib/ircUrlParser"; import { @@ -32,20 +33,96 @@ import { getPreviewStyles, isValidFormattingType, } from "../../lib/messageFormatter"; -import useStore from "../../store"; +import useStore, { serverSupportsMultiline } from "../../store"; import type { Message as MessageType, User } from "../../types"; +import { CollapsedEventMessage } from "../message/CollapsedEventMessage"; import { MessageItem } from "../message/MessageItem"; import AutocompleteDropdown from "../ui/AutocompleteDropdown"; import BlankPage from "../ui/BlankPage"; import ColorPicker from "../ui/ColorPicker"; import EmojiAutocompleteDropdown from "../ui/EmojiAutocompleteDropdown"; import DiscoverGrid from "../ui/HomeScreen"; +import LoadingSpinner from "../ui/LoadingSpinner"; import ReactionModal from "../ui/ReactionModal"; import UserContextMenu from "../ui/UserContextMenu"; const EMPTY_ARRAY: User[] = []; let lastTypingTime = 0; +// Helper function to split long messages while respecting IRC protocol limits +const splitLongMessage = (message: string, target = "#channel"): string[] => { + // Calculate IRC protocol overhead for a PRIVMSG (excluding message tags) + // Format: :nick!user@host PRIVMSG #target :message\r\n + // Message tags don't count toward the 512-byte limit + + // Conservative estimates for variable parts (as per IRC spec recommendations) + const maxNickLength = 20; + const maxUserLength = 20; + const maxHostLength = 63; + const targetLength = target.length; + + // Fixed protocol parts (excluding tags) + const protocolOverhead = + 1 + // ':' + maxNickLength + + 1 + // '!' + maxUserLength + + 1 + // '@' + maxHostLength + + 1 + // ' ' + 7 + // 'PRIVMSG' + 1 + // ' ' + targetLength + + 2 + // ' :' + 2; // '\r\n' + + const safetyBuffer = 10; // Small safety margin + + // Available space for the actual message content + const maxMessageLength = 512 - protocolOverhead - safetyBuffer; + + console.log( + `[MULTILINE] Protocol overhead: ${protocolOverhead}, Max message length: ${maxMessageLength}, Input length: ${message.length}`, + ); + + if (message.length <= maxMessageLength) { + return [message]; + } + + const lines: string[] = []; + let currentLine = ""; + const words = message.split(" "); + + for (const word of words) { + if (word.length > maxMessageLength) { + // If a single word is too long, we have to break it + if (currentLine) { + lines.push(currentLine.trim()); + currentLine = ""; + } + + // Split the long word + for (let i = 0; i < word.length; i += maxMessageLength) { + lines.push(word.slice(i, i + maxMessageLength)); + } + } else if (`${currentLine} ${word}`.length > maxMessageLength) { + // Adding this word would exceed the limit + if (currentLine) { + lines.push(currentLine.trim()); + } + currentLine = word; + } else { + currentLine = currentLine ? `${currentLine} ${word}` : word; + } + } + + if (currentLine) { + lines.push(currentLine.trim()); + } + + return lines.filter((line) => line.length > 0); +}; + export const TypingIndicator: React.FC<{ serverId: string; channelId: string; @@ -110,8 +187,8 @@ export const ChatArea: React.FC<{ }); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); - const inputRef = useRef(null); - const { currentUser } = useStore(); + const inputRef = useRef(null); + const { servers, ui: { @@ -119,6 +196,11 @@ export const ChatArea: React.FC<{ selectedChannelId, selectedPrivateChatId, isMemberListVisible, + isSettingsModalOpen, + isUserProfileModalOpen, + isAddServerModalOpen, + isChannelListModalOpen, + isChannelRenameModalOpen, }, toggleMemberList, openPrivateChat, @@ -127,8 +209,35 @@ export const ChatArea: React.FC<{ joinChannel, toggleAddServerModal, redactMessage, + globalSettings, } = useStore(); + // Get the current user for the selected server with metadata from store + const currentUser = useMemo(() => { + if (!selectedServerId) return null; + + // Get the current user's username from IRCClient + const ircCurrentUser = ircClient.getCurrentUser(selectedServerId); + if (!ircCurrentUser) return null; + + // Find the current user in the server's channel data to get metadata + const selectedServer = servers.find((s) => s.id === selectedServerId); + if (!selectedServer) return ircCurrentUser; + + // Look for the user in any channel to get their metadata + for (const channel of selectedServer.channels) { + const userWithMetadata = channel.users.find( + (u) => u.username === ircCurrentUser.username, + ); + if (userWithMetadata) { + return userWithMetadata; + } + } + + // If not found in channels, return the basic IRC user + return ircCurrentUser; + }, [selectedServerId, servers]); + // Tab completion hook const tabCompletion = useTabCompletion(); @@ -293,26 +402,168 @@ export const ChatArea: React.FC<{ `PRIVMSG ${selectedChannel ? selectedChannel.name : ""} :\u0001ACTION ${actionMessage}\u0001`, ); } else { - ircClient.sendRaw( - selectedServerId, - `${commandName} :${args.join(" ")}`, - ); + const fullCommand = + args.length > 0 ? `${commandName} ${args.join(" ")}` : commandName; + console.log(`[IRC] Sending command: ${fullCommand}`); + ircClient.sendRaw(selectedServerId, fullCommand); } } else { - // Format the message with color and styling - const formattedText = formatMessageForIrc(messageText, { - color: selectedColor || "inherit", - formatting: selectedFormatting, - }); - // Determine target: channel name or username for private messages const target = selectedChannel?.name ?? selectedPrivateChat?.username ?? ""; - ircClient.sendRaw( - selectedServerId, - `${localReplyTo ? `@+draft/reply=${localReplyTo.id};` : ""} PRIVMSG ${target} :${formattedText}`, - ); + // Check if message contains newlines or is very long + const lines = messageText.split("\n"); + const supportsMultiline = serverSupportsMultiline(selectedServerId); + const hasMultipleLines = lines.length > 1; + + // Calculate the same limit as splitLongMessage for consistency + const maxNickLength = 20; + const maxUserLength = 20; + const maxHostLength = 63; + const protocolOverhead = + 1 + + maxNickLength + + 1 + + maxUserLength + + 1 + + maxHostLength + + 1 + + 7 + + 1 + + target.length + + 2 + + 2; + const maxMessageLength = 512 - protocolOverhead - 10; // 10 byte safety buffer + const isSingleLongLine = + lines.length === 1 && messageText.length > maxMessageLength; + + if (supportsMultiline && (hasMultipleLines || isSingleLongLine)) { + // Send as multiline message using BATCH + const batchId = `ml_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const replyPrefix = localReplyTo + ? `@+draft/reply=${localReplyTo.id};` + : ""; + ircClient.sendRaw( + selectedServerId, + `${replyPrefix}BATCH +${batchId} draft/multiline ${target}`, + ); + + if (hasMultipleLines) { + // Case 1: Multi-line message (preserve line breaks) + lines.forEach((line) => { + const formattedLine = formatMessageForIrc(line, { + color: selectedColor || "inherit", + formatting: selectedFormatting, + }); + + // Check if this individual line is too long and needs splitting + const maxLineLengthForTarget = + 512 - + (1 + 20 + 1 + 20 + 1 + 63 + 1 + 7 + 1 + target.length + 2 + 2) - + 10; + if (formattedLine.length > maxLineLengthForTarget) { + const splitLines = splitLongMessage(formattedLine, target); + splitLines.forEach((splitLine: string, index: number) => { + if (index === 0) { + // First part goes normally + ircClient.sendRaw( + selectedServerId, + `@batch=${batchId} PRIVMSG ${target} :${splitLine}`, + ); + } else { + // Subsequent parts use multiline-concat to join without line break + ircClient.sendRaw( + selectedServerId, + `@batch=${batchId};draft/multiline-concat PRIVMSG ${target} :${splitLine}`, + ); + } + }); + } else { + ircClient.sendRaw( + selectedServerId, + `@batch=${batchId} PRIVMSG ${target} :${formattedLine}`, + ); + } + }); + } else { + // Case 2: Single very long line (split and concat) + const formattedText = formatMessageForIrc(messageText, { + color: selectedColor || "inherit", + formatting: selectedFormatting, + }); + + const splitLines = splitLongMessage(formattedText, target); + splitLines.forEach((splitLine: string, index: number) => { + if (index === 0) { + // First part goes normally + ircClient.sendRaw( + selectedServerId, + `@batch=${batchId} PRIVMSG ${target} :${splitLine}`, + ); + } else { + // Subsequent parts use multiline-concat to join without separation + ircClient.sendRaw( + selectedServerId, + `@batch=${batchId};draft/multiline-concat PRIVMSG ${target} :${splitLine}`, + ); + } + }); + } + + ircClient.sendRaw(selectedServerId, `BATCH -${batchId}`); + } else if (hasMultipleLines && !supportsMultiline) { + // Handle fallback based on user preference + if (globalSettings.autoFallbackToSingleLine) { + // Concatenate with spaces and send as single message + const combinedText = lines.join(" "); + const formattedText = formatMessageForIrc(combinedText, { + color: selectedColor || "inherit", + formatting: selectedFormatting, + }); + + // Split if too long + const splitLines = splitLongMessage(formattedText, target); + splitLines.forEach((line: string) => { + ircClient.sendRaw( + selectedServerId, + `${localReplyTo ? `@+draft/reply=${localReplyTo.id};` : ""} PRIVMSG ${target} :${line}`, + ); + }); + } else { + // Send as separate messages + lines.forEach((line) => { + const formattedLine = formatMessageForIrc(line, { + color: selectedColor || "inherit", + formatting: selectedFormatting, + }); + + // Split long lines + const splitLines = splitLongMessage(formattedLine, target); + splitLines.forEach((splitLine: string) => { + ircClient.sendRaw( + selectedServerId, + `${localReplyTo ? `@+draft/reply=${localReplyTo.id};` : ""} PRIVMSG ${target} :${splitLine}`, + ); + }); + }); + } + } else { + // Send as regular single message + const formattedText = formatMessageForIrc(messageText, { + color: selectedColor || "inherit", + formatting: selectedFormatting, + }); + + // Split if too long + const splitLines = splitLongMessage(formattedText, target); + splitLines.forEach((line: string) => { + ircClient.sendRaw( + selectedServerId, + `${localReplyTo ? `@+draft/reply=${localReplyTo.id};` : ""} PRIVMSG ${target} :${line}`, + ); + }); + } // For private messages, manually add our own message to the chat // since the server doesn't echo private messages back to us @@ -342,8 +593,17 @@ export const ChatArea: React.FC<{ tabCompletion.resetCompletion(); } + // Reset textarea height + if (inputRef.current) { + inputRef.current.style.height = "auto"; + } + // Send typing done notification - if (selectedChannel?.name || selectedPrivateChat?.username) { + const storeState = useStore.getState(); + if ( + storeState.globalSettings.sendTypingNotifications && + (selectedChannel?.name || selectedPrivateChat?.username) + ) { const target = selectedChannel?.name ?? selectedPrivateChat?.username; ircClient.sendTyping( selectedServerId as string, @@ -388,22 +648,35 @@ export const ChatArea: React.FC<{ return; } - if (e.key === "Enter" && !e.shiftKey) { + // Handle Enter key behavior based on settings + if (e.key === "Enter") { + const shouldCreateNewline = + globalSettings.enableMultilineInput && + (globalSettings.multilineOnShiftEnter ? e.shiftKey : !e.shiftKey); + + if (shouldCreateNewline) { + // Allow the default behavior (add newline) + return; + } + // Send message e.preventDefault(); handleSendMessage(); // Send typing done notification - if (selectedChannel?.name) { - ircClient.sendTyping( - selectedServerId ?? "", - selectedChannel.name, - false, - ); - } else if (selectedPrivateChat?.username) { - ircClient.sendTyping( - selectedServerId ?? "", - selectedPrivateChat.username, - false, - ); + const storeState = useStore.getState(); + if (storeState.globalSettings.sendTypingNotifications) { + if (selectedChannel?.name) { + ircClient.sendTyping( + selectedServerId ?? "", + selectedChannel.name, + false, + ); + } else if (selectedPrivateChat?.username) { + ircClient.sendTyping( + selectedServerId ?? "", + selectedPrivateChat.username, + false, + ); + } } lastTypingTime = 0; return; @@ -492,7 +765,7 @@ export const ChatArea: React.FC<{ } }; - const handleInputChange = (e: React.ChangeEvent) => { + const handleInputChange = (e: React.ChangeEvent) => { const newText = e.target.value; const newCursorPosition = e.target.selectionStart || 0; @@ -500,6 +773,13 @@ export const ChatArea: React.FC<{ setCursorPosition(newCursorPosition); handleUpdatedText(newText); + // Auto-resize textarea + const textarea = e.target; + textarea.style.height = "auto"; + const scrollHeight = textarea.scrollHeight; + const maxHeight = 128; // 8 lines (16px line height * 8) + textarea.style.height = `${Math.min(scrollHeight, maxHeight)}px`; + // Reset tab completion if text changed from non-tab input if (tabCompletion.isActive) { tabCompletion.resetCompletion(); @@ -515,8 +795,8 @@ export const ChatArea: React.FC<{ setShowEmojiAutocomplete(false); }; - const handleInputClick = (e: React.MouseEvent) => { - const target = e.target as HTMLInputElement; + const handleInputClick = (e: React.MouseEvent) => { + const target = e.target as HTMLTextAreaElement; const newCursorPos = target.selectionStart || 0; setCursorPosition(newCursorPos); }; @@ -703,16 +983,20 @@ export const ChatArea: React.FC<{ } }; - const handleInputKeyUp = (e: React.KeyboardEvent) => { + const handleInputKeyUp = (e: React.KeyboardEvent) => { // Skip if it was Tab key (handled by keyDown) if (e.key === "Tab") return; - const target = e.target as HTMLInputElement; + const target = e.target as HTMLTextAreaElement; const newCursorPos = target.selectionStart || 0; setCursorPosition(newCursorPos); }; const handleUpdatedText = (text: string) => { + // Check if typing notifications are enabled + const { globalSettings } = useStore.getState(); + if (!globalSettings.sendTypingNotifications) return; + if (text.length > 0 && text[0] !== "/") { const server = useStore .getState() @@ -929,11 +1213,30 @@ export const ChatArea: React.FC<{ const isNarrowView = useMediaQuery(); // Focus input on channel change + // biome-ignore lint/correctness/useExhaustiveDependencies(selectedChannelId): Only focus when channel changes + // biome-ignore lint/correctness/useExhaustiveDependencies(selectedPrivateChatId): Only focus when private chat changes useEffect(() => { if ("__TAURI__" in window && ["android", "ios"].includes(platform())) return; + // Don't steal focus if any modal is open + if ( + isSettingsModalOpen || + isUserProfileModalOpen || + isAddServerModalOpen || + isChannelListModalOpen || + isChannelRenameModalOpen + ) + return; inputRef.current?.focus(); - }); + }, [ + selectedChannelId, + selectedPrivateChatId, + isSettingsModalOpen, + isUserProfileModalOpen, + isAddServerModalOpen, + isChannelListModalOpen, + isChannelRenameModalOpen, + ]); return (
@@ -994,9 +1297,11 @@ export const ChatArea: React.FC<{ {selectedChannel && (() => { - const { currentUser } = useStore.getState(); + const serverCurrentUser = selectedServerId + ? ircClient.getCurrentUser(selectedServerId) + : null; const channelUser = selectedChannel.users.find( - (u) => u.username === currentUser?.username, + (u) => u.username === serverCurrentUser?.username, ); const isOperator = channelUser?.status?.includes("@") || @@ -1055,42 +1360,97 @@ export const ChatArea: React.FC<{ ref={messagesContainerRef} className="flex-grow overflow-y-auto flex flex-col bg-discord-dark-200 text-discord-text-normal relative" > - {channelMessages.map((message, index) => { - const previousMessage = channelMessages[index - 1]; - const showHeader = - !previousMessage || - previousMessage.userId !== message.userId || - new Date(message.timestamp).getTime() - - new Date(previousMessage.timestamp).getTime() > - 5 * 60 * 1000; - - return ( - - handleUsernameClick(e, username, serverId, avatarElement) - } - onIrcLinkClick={handleIrcLinkClick} - onReactClick={handleReactClick} - selectedServerId={selectedServerId} - onReactionUnreact={handleReactionUnreact} - onOpenReactionModal={handleOpenReactionModal} - onDirectReaction={handleDirectReaction} - users={selectedChannel?.users || []} - onRedactMessage={handleRedactMessage} + {selectedChannel?.isLoadingHistory ? ( + // Show loading spinner when channel is loading history +
+ - ); - })} +
+ ) : ( + // Show messages when not loading + (() => { + // Group consecutive events before rendering + const eventGroups = groupConsecutiveEvents(channelMessages); + + return eventGroups.map((group) => { + if (group.type === "eventGroup") { + // Create a stable key from the first and last message IDs in the group + const firstId = group.messages[0]?.id || ""; + const lastId = + group.messages[group.messages.length - 1]?.id || ""; + const groupKey = `group-${firstId}-${lastId}`; + + return ( + + handleUsernameClick( + e, + username, + serverId, + avatarElement, + ) + } + /> + ); + } + // Single message - find its original index for date/header logic + const message = group.messages[0]; + const originalIndex = channelMessages.findIndex( + (m) => m.id === message.id, + ); + const previousMessage = channelMessages[originalIndex - 1]; + const showHeader = + !previousMessage || + previousMessage.userId !== message.userId || + new Date(message.timestamp).getTime() - + new Date(previousMessage.timestamp).getTime() > + 5 * 60 * 1000; + + return ( + + handleUsernameClick(e, username, serverId, avatarElement) + } + onIrcLinkClick={handleIrcLinkClick} + onReactClick={handleReactClick} + selectedServerId={selectedServerId} + onReactionUnreact={handleReactionUnreact} + onOpenReactionModal={handleOpenReactionModal} + onDirectReaction={handleDirectReaction} + users={selectedChannel?.users || []} + onRedactMessage={handleRedactMessage} + /> + ); + }); + })() + )}
@@ -1135,9 +1495,8 @@ export const ChatArea: React.FC<{
)} - + {!isOwnUser && ( + + )} {canModerate && !isOwnUser && ( <> +
+ {globalCustomMentions.length > 0 && ( +
+ {globalCustomMentions.map((mention) => ( + + {mention} + + + ))} +
+ )} + + ); +}; + +// Component for managing ignore list +const IgnoreList: React.FC<{ + ignoreList: string[]; + addToIgnoreList: (pattern: string) => void; + removeFromIgnoreList: (pattern: string) => void; +}> = ({ ignoreList, addToIgnoreList, removeFromIgnoreList }) => { + const [newPattern, setNewPattern] = useState(""); + const [validationError, setValidationError] = useState(""); + + const handleAddPattern = () => { + const trimmed = newPattern.trim(); + if (!trimmed) { + setValidationError("Pattern cannot be empty"); + return; + } + + if (!isValidIgnorePattern(trimmed)) { + setValidationError( + "Invalid pattern format. Use nick!user@host format (wildcards * allowed)", + ); + return; + } + + if (ignoreList.includes(trimmed)) { + setValidationError("Pattern already exists"); + return; + } + + addToIgnoreList(trimmed); + setNewPattern(""); + setValidationError(""); + }; + + const handleRemovePattern = (pattern: string) => { + removeFromIgnoreList(pattern); + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleAddPattern(); + } + }; + + const handleInputChange = (e: React.ChangeEvent) => { + setNewPattern(e.target.value); + setValidationError(""); // Clear error when user types + }; + + return ( +
+
+
+ + +
+ {validationError && ( +

{validationError}

+ )} +

+ Use * for wildcards. Examples: baduser!*@*, *!*@spammer.com, + nick123!user@host.net +

+
+ {ignoreList.length > 0 && ( +
+

+ {ignoreList.length} ignored pattern + {ignoreList.length !== 1 ? "s" : ""}: +

+
+ {ignoreList.map((pattern) => ( +
+ + {pattern} + + +
+ ))} +
+
+ )} + {ignoreList.length === 0 && ( +

+ No users ignored +

+ )} +
+ ); +}; + +const UserSettings: React.FC = React.memo(() => { const { toggleUserProfileModal, - currentUser, servers, ui, metadataSet, sendRaw, setName, + changeNick, + globalSettings: { + enableNotificationSounds: globalEnableNotificationSounds, + notificationSound: globalNotificationSound, + enableHighlights: globalEnableHighlights, + sendTypingNotifications: globalSendTypingNotifications, + nickname: globalNickname, + accountName: globalAccountName, + accountPassword: globalAccountPassword, + customMentions: globalCustomMentions, + ignoreList: globalIgnoreList, + showEvents: globalShowEvents, + showNickChanges: globalShowNickChanges, + showJoinsParts: globalShowJoinsParts, + showQuits: globalShowQuits, + enableMultilineInput: globalEnableMultilineInput, + multilineOnShiftEnter: globalMultilineOnShiftEnter, + autoFallbackToSingleLine: globalAutoFallbackToSingleLine, + }, + updateGlobalSettings, + addToIgnoreList, + removeFromIgnoreList, } = useStore(); - const currentServer = servers.find((s) => s.id === ui.selectedServerId); - const supportsMetadata = currentServer - ? serverSupportsMetadata(currentServer.id) - : false; - // Metadata state + // Memoize the current server and metadata support to prevent unnecessary re-renders + const currentServer = useMemo( + () => servers.find((s) => s.id === ui.selectedServerId), + [servers, ui.selectedServerId], + ); + + // Get the current user for the selected server with metadata from store + const currentUser = useMemo(() => { + if (!currentServer) return null; + + // Get the current user's username from IRCClient + const ircCurrentUser = ircClient.getCurrentUser(currentServer.id); + if (!ircCurrentUser) return null; + + // Find the current user in the server's channel data to get metadata + for (const channel of currentServer.channels) { + const userWithMetadata = channel.users.find( + (u) => u.username === ircCurrentUser.username, + ); + if (userWithMetadata) { + return userWithMetadata; + } + } + + // If not found in channels, return the basic IRC user + return ircCurrentUser; + }, [currentServer]); + + const supportsMetadata = useMemo( + () => (currentServer ? serverSupportsMetadata(currentServer.id) : false), + [currentServer], + ); + const isHostedChatMode = __HIDE_SERVER_LIST__; + + // Category state + const [activeCategory, setActiveCategory] = + useState("profile"); + + // Profile metadata state const [avatar, setAvatar] = useState(""); const [displayName, setDisplayName] = useState(""); const [realname, setRealname] = useState(""); const [homepage, setHomepage] = useState(""); const [status, setStatus] = useState(""); - const [color, setColor] = useState("#800040"); + const [color, setColor] = useState(""); const [bot, setBot] = useState(""); - // Load existing metadata on mount + // Settings state + const [enableNotificationSounds, setEnableNotificationSounds] = useState( + globalEnableNotificationSounds, + ); + const [notificationSound, setNotificationSound] = useState( + globalNotificationSound, + ); + const [notificationSoundFile, setNotificationSoundFile] = + useState(null); + const [enableHighlights, setEnableHighlights] = useState( + globalEnableHighlights, + ); + const [sendTypingNotifications, setSendTypingNotifications] = useState( + globalSendTypingNotifications, + ); + + // Account state (for hosted chat mode) + const [nickname, setNickname] = useState( + globalNickname || currentUser?.username || "", + ); + const [newNickname, setNewNickname] = useState(currentUser?.username || ""); + const [accountName, setAccountName] = useState(globalAccountName); + const [accountPassword, setAccountPassword] = useState(globalAccountPassword); + + // Original values for change tracking + const [originalValues, setOriginalValues] = useState<{ + avatar: string; + displayName: string; + realname: string; + homepage: string; + status: string; + color: string; + bot: string; + newNickname: string; + enableNotificationSounds: boolean; + notificationSound: string; + enableHighlights: boolean; + sendTypingNotifications: boolean; + nickname: string; + accountName: string; + accountPassword: string; + } | null>(null); + + // Track if there are unsaved changes + const hasUnsavedChanges = + originalValues && + (avatar !== originalValues.avatar || + displayName !== originalValues.displayName || + realname !== originalValues.realname || + homepage !== originalValues.homepage || + status !== originalValues.status || + color !== originalValues.color || + bot !== originalValues.bot || + newNickname !== originalValues.newNickname || + enableNotificationSounds !== originalValues.enableNotificationSounds || + notificationSound !== originalValues.notificationSound || + enableHighlights !== originalValues.enableHighlights || + sendTypingNotifications !== originalValues.sendTypingNotifications || + nickname !== originalValues.nickname || + accountName !== originalValues.accountName || + accountPassword !== originalValues.accountPassword); + + const fileInputRef = useRef(null); + + // Refs for input fields to preserve focus during re-renders + const nicknameInputRef = useRef(null); + const displayNameInputRef = useRef(null); + const avatarInputRef = useRef(null); + const statusInputRef = useRef(null); + const colorInputRef = useRef(null); + const botInputRef = useRef(null); + const realnameInputRef = useRef(null); + + // Memoized onChange handlers to prevent unnecessary re-renders + const handleNewNicknameChange = useCallback( + (e: React.ChangeEvent) => { + setNewNickname(e.target.value); + // Schedule focus restoration after React's render cycle + setTimeout(() => { + if (document.activeElement !== nicknameInputRef.current) { + nicknameInputRef.current?.focus(); + } + }, 0); + }, + [], + ); + + const handleDisplayNameChange = useCallback( + (e: React.ChangeEvent) => { + setDisplayName(e.target.value); + setTimeout(() => { + if (document.activeElement !== displayNameInputRef.current) { + displayNameInputRef.current?.focus(); + } + }, 0); + }, + [], + ); + + const handleAvatarChange = useCallback( + (e: React.ChangeEvent) => { + setAvatar(e.target.value); + setTimeout(() => { + if (document.activeElement !== avatarInputRef.current) { + avatarInputRef.current?.focus(); + } + }, 0); + }, + [], + ); + + const handleHomepageChange = useCallback( + (e: React.ChangeEvent) => { + setHomepage(e.target.value); + }, + [], + ); + + const handleStatusChange = useCallback( + (e: React.ChangeEvent) => { + setStatus(e.target.value); + setTimeout(() => { + if (document.activeElement !== statusInputRef.current) { + statusInputRef.current?.focus(); + } + }, 0); + }, + [], + ); + + const handleColorChange = useCallback( + (e: React.ChangeEvent) => { + setColor(e.target.value); + setTimeout(() => { + if (document.activeElement !== colorInputRef.current) { + colorInputRef.current?.focus(); + } + }, 0); + }, + [], + ); + + const handleBotChange = useCallback( + (e: React.ChangeEvent) => { + setBot(e.target.value); + setTimeout(() => { + if (document.activeElement !== botInputRef.current) { + botInputRef.current?.focus(); + } + }, 0); + }, + [], + ); + + const handleRealnameChange = useCallback( + (e: React.ChangeEvent) => { + setRealname(e.target.value); + setTimeout(() => { + if (document.activeElement !== realnameInputRef.current) { + realnameInputRef.current?.focus(); + } + }, 0); + }, + [], + ); + + const handleNicknameChange = useCallback( + (e: React.ChangeEvent) => { + setNickname(e.target.value); + }, + [], + ); + + const handleAccountNameChange = useCallback( + (e: React.ChangeEvent) => { + setAccountName(e.target.value); + }, + [], + ); + + const handleAccountPasswordChange = useCallback( + (e: React.ChangeEvent) => { + setAccountPassword(e.target.value); + }, + [], + ); + + // Function to handle closing with unsaved changes warning + const handleClose = () => { + if (hasUnsavedChanges) { + const confirmClose = window.confirm( + "You have unsaved changes. Are you sure you want to close without saving?", + ); + if (!confirmClose) { + return; + } + } + // Reset original values when closing so it will reinitialize next time + setOriginalValues(null); + toggleUserProfileModal(false); + }; + + // Audio playback utility + const playNotificationSound = async (soundFile?: File | string | null) => { + try { + let audioSrc: string; + + if (soundFile instanceof File) { + // Play custom uploaded sound from File object + audioSrc = URL.createObjectURL(soundFile); + } else if (typeof soundFile === "string") { + // Play custom sound from URL string (for previously saved sounds) + audioSrc = soundFile; + } else { + // Play default notification sound (we'll use a simple beep) + // Create a simple beep sound using Web Audio API + const AudioContextClass = + window.AudioContext || + (window as unknown as { webkitAudioContext: typeof AudioContext }) + .webkitAudioContext; + const audioContext = new AudioContextClass(); + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + + oscillator.frequency.setValueAtTime(800, audioContext.currentTime); + oscillator.type = "sine"; + + gainNode.gain.setValueAtTime(0, audioContext.currentTime); + gainNode.gain.linearRampToValueAtTime( + 0.1, + audioContext.currentTime + 0.01, + ); + gainNode.gain.exponentialRampToValueAtTime( + 0.01, + audioContext.currentTime + 0.5, + ); + + oscillator.start(audioContext.currentTime); + oscillator.stop(audioContext.currentTime + 0.5); + return; + } + + const audio = new Audio(audioSrc); + audio.volume = 0.5; // Set reasonable volume + await audio.play(); + + // Clean up object URL if it was created from a File + if (soundFile instanceof File) { + setTimeout(() => URL.revokeObjectURL(audioSrc), 1000); + } + } catch (error) { + console.error("Failed to play notification sound:", error); + // Fallback to default browser notification sound + if (soundFile) { + // If custom sound fails, try default sound + playNotificationSound(); + } + } + }; + + // Load existing metadata on mount - only once when modal opens useEffect(() => { - if (currentUser) { - setAvatar(currentUser.metadata?.avatar?.value || ""); - setDisplayName(currentUser.metadata?.["display-name"]?.value || ""); - setRealname(currentUser.displayName || ""); - setHomepage(currentUser.metadata?.homepage?.value || ""); - setStatus(currentUser.metadata?.status?.value || ""); - setColor(currentUser.metadata?.color?.value || "#800040"); - setBot(currentUser.metadata?.bot?.value || ""); + if (currentUser && !originalValues) { + const avatarValue = currentUser.metadata?.avatar?.value || ""; + const displayNameValue = + currentUser.metadata?.["display-name"]?.value || ""; + const realnameValue = currentUser.displayName || ""; + const homepageValue = currentUser.metadata?.homepage?.value || ""; + const statusValue = currentUser.metadata?.status?.value || ""; + const colorValue = currentUser.metadata?.color?.value || ""; + const botValue = currentUser.metadata?.bot?.value || ""; + const nicknameValue = currentUser.username || ""; + + // Set current form values + setAvatar(avatarValue); + setDisplayName(displayNameValue); + setRealname(realnameValue); + setHomepage(homepageValue); + setStatus(statusValue); + setColor(colorValue); + setBot(botValue); + setNewNickname(nicknameValue); + + // Set global settings values + setEnableNotificationSounds(globalEnableNotificationSounds); + setNotificationSound(globalNotificationSound); + setEnableHighlights(globalEnableHighlights); + setSendTypingNotifications(globalSendTypingNotifications); + setNickname(globalNickname || currentUser?.username || ""); + setAccountName(globalAccountName); + setAccountPassword(globalAccountPassword); + + // Set original values for change tracking - only once + setOriginalValues({ + avatar: avatarValue, + displayName: displayNameValue, + realname: realnameValue, + homepage: homepageValue, + status: statusValue, + color: colorValue, + bot: botValue, + newNickname: nicknameValue, + enableNotificationSounds: globalEnableNotificationSounds, + notificationSound: globalNotificationSound, + enableHighlights: globalEnableHighlights, + sendTypingNotifications: globalSendTypingNotifications, + nickname: globalNickname || currentUser?.username || "", + accountName: globalAccountName, + accountPassword: globalAccountPassword, + }); } - }, [currentUser]); + }, [ + currentUser?.id, + currentUser?.displayName, + currentUser?.metadata?.["display-name"]?.value, + currentUser?.metadata?.avatar?.value, + currentUser?.metadata?.bot?.value, + currentUser?.metadata?.color?.value, + currentUser?.metadata?.homepage?.value, + currentUser?.metadata?.status?.value, + currentUser?.username, + globalAccountName, + globalAccountPassword, + globalEnableHighlights, + globalEnableNotificationSounds, + globalNickname, + globalNotificationSound, + globalSendTypingNotifications, + currentUser, + originalValues, + ]); // Only depend on user ID - removed all other dependencies const handleSaveMetadata = (key: string, value: string) => { if (currentServer && currentUser) { @@ -51,242 +651,725 @@ const UserSettings: React.FC = () => { } }; + const handleSoundFileSelect = () => { + fileInputRef.current?.click(); + }; + + const handleSoundFileChange = ( + event: React.ChangeEvent, + ) => { + const file = event.target.files?.[0]; + if (file) { + const url = URL.createObjectURL(file); + setNotificationSound(url); + setNotificationSoundFile(file); + } + }; + + const handleNickChange = () => { + if ( + currentServer && + newNickname.trim() && + newNickname.trim() !== currentUser?.username + ) { + changeNick(currentServer.id, newNickname.trim()); + } + }; + const handleSaveAll = () => { + console.log("[USER_SETTINGS] handleSaveAll called"); + console.log("[USER_SETTINGS] originalValues:", originalValues); + console.log("[USER_SETTINGS] current values:", { + displayName, + color, + avatar, + status, + homepage, + bot, + }); + console.log("[USER_SETTINGS] supportsMetadata:", supportsMetadata); + + if (!originalValues) { + console.log("[USER_SETTINGS] No original values, skipping save"); + return; // Don't save if original values aren't set yet + } + if (currentServer && currentUser) { - // Handle display name (only when metadata is supported) + console.log( + "[USER_SETTINGS] Processing metadata updates for server:", + currentServer.id, + ); + // Handle profile metadata (only when metadata is supported and values have changed) if (supportsMetadata) { - try { - metadataSet( - currentServer.id, - currentUser.username, - "display-name", - displayName || undefined, - ); - } catch (error) { - console.error("Failed to set display name metadata:", error); + // Only update display name if it changed + if (displayName !== originalValues.displayName) { + console.log("[USER_SETTINGS] Updating display-name:", displayName); + try { + metadataSet( + currentServer.id, + "*", // Use * to refer to current user (self) + "display-name", + displayName || undefined, + ); + } catch (error) { + console.error("Failed to set display name metadata:", error); + } } - } - if (supportsMetadata) { const metadataUpdates = [ - { key: "avatar", value: avatar }, - { key: "homepage", value: homepage }, - { key: "status", value: status }, - { key: "color", value: color }, - { key: "bot", value: bot }, + { key: "avatar", value: avatar, original: originalValues.avatar }, + { + key: "homepage", + value: homepage, + original: originalValues.homepage, + }, + { key: "status", value: status, original: originalValues.status }, + { key: "color", value: color, original: originalValues.color }, + { key: "bot", value: bot, original: originalValues.bot }, ]; - metadataUpdates.forEach(({ key, value }) => { - try { - metadataSet( - currentServer.id, - currentUser.username, - key, - value || undefined, + console.log( + "[USER_SETTINGS] Checking metadata updates:", + metadataUpdates, + ); + + metadataUpdates.forEach(({ key, value, original }) => { + // Only update if the value has changed + if (value !== original) { + console.log( + `[USER_SETTINGS] Updating ${key}: "${original}" -> "${value}"`, ); - } catch (error) { - console.error(`Failed to set ${key} metadata:`, error); + try { + metadataSet( + currentServer.id, + "*", // Use * to refer to current user (self) + key, + value || undefined, + ); + } catch (error) { + console.error(`Failed to set ${key} metadata:`, error); + } + } else { + console.log(`[USER_SETTINGS] No change for ${key}: "${value}"`); } }); } - // Handle realname - try { - setName(currentServer.id, realname); - } catch (error) { - console.error("Failed to set realname:", error); + // Handle realname only if it changed + if (realname !== originalValues.realname) { + try { + setName(currentServer.id, realname); + } catch (error) { + console.error("Failed to set realname:", error); + } + } + } + + // Save global settings only if they changed + const globalSettingsUpdates: Record = {}; + + if (enableNotificationSounds !== originalValues.enableNotificationSounds) { + globalSettingsUpdates.enableNotificationSounds = enableNotificationSounds; + } + if (notificationSound !== originalValues.notificationSound) { + globalSettingsUpdates.notificationSound = notificationSound; + } + if (enableHighlights !== originalValues.enableHighlights) { + globalSettingsUpdates.enableHighlights = enableHighlights; + } + if (sendTypingNotifications !== originalValues.sendTypingNotifications) { + globalSettingsUpdates.sendTypingNotifications = sendTypingNotifications; + } + + if (isHostedChatMode) { + if (nickname !== originalValues.nickname) { + globalSettingsUpdates.nickname = nickname; } + if (accountName !== originalValues.accountName) { + globalSettingsUpdates.accountName = accountName; + } + if (accountPassword !== originalValues.accountPassword) { + globalSettingsUpdates.accountPassword = accountPassword; + } + } + + // Only update global settings if there are changes + if (Object.keys(globalSettingsUpdates).length > 0) { + updateGlobalSettings(globalSettingsUpdates); } + + // Reset original values when closing after save + setOriginalValues(null); toggleUserProfileModal(false); }; - return ( -
-
-
-

User Settings

- + const categories = [ + { id: "profile" as const, name: "Profile", icon: FaUser }, + { id: "notifications" as const, name: "Notifications", icon: FaBell }, + { id: "preferences" as const, name: "Preferences", icon: FaCog }, + ...(isHostedChatMode + ? [{ id: "account" as const, name: "Account", icon: FaServer }] + : []), + ]; + + const renderProfileSettings = () => ( +
+ +
+ + {newNickname.trim() && + newNickname.trim() !== currentUser?.username && ( + + )}
+
-
-
- + {supportsMetadata && ( + <> + + + + + + + + + + + + + + + + +
+ + +
+
+ + + + + + )} + + + + + + {!supportsMetadata && ( +
+

Limited Profile Support

+

+ This server does not support user metadata. Advanced profile options + (display name, avatar, status, etc.) will appear when connecting to + a server with IRC metadata support. +

+
+ )} +
+ ); + + const renderNotificationSettings = () => ( +
+ +
+ + {enableNotificationSounds && ( + + )} +
+
+ + {enableNotificationSounds && ( + +
+ + {notificationSound && ( + <> + + Custom sound selected + + + + )}
+ +
+ )} - {supportsMetadata && ( - <> -
- - setDisplayName(e.target.value)} - placeholder="Alternative display name" - className="w-full bg-discord-dark-400 text-discord-text-normal rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-discord-primary" - /> -
+ + + -
- - setAvatar(e.target.value)} - placeholder="https://example.com/avatar.jpg" - className="w-full bg-discord-dark-400 text-discord-text-normal rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-discord-primary" - /> -
+ {enableHighlights && ( + + + + )} +
+ ); -
- - setHomepage(e.target.value)} - placeholder="https://example.com" - className="w-full bg-discord-dark-400 text-discord-text-normal rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-discord-primary" - /> -
+ const renderPreferencesSettings = () => ( +
+ +
+ -
-
+ -
- + + + + + +
+ + + {globalEnableMultilineInput && ( +
+
+ + Require Shift+Enter for new lines (uncheck to always allow + Enter for new lines) + + -
- +
- - )} - - {!supportsMetadata && ( -
- This server does not support user metadata. Metadata options will - appear here when connecting to a server with draft/metadata - support. + + Auto-concatenate multiline messages when server doesn't + support multiline + +
)} +
+
+ + + + + + + + +
+ ); + + const renderAccountSettings = () => ( +
+ + + -
- - + + + + + +
+ ); + + const renderActiveCategory = () => { + switch (activeCategory) { + case "profile": + return renderProfileSettings(); + case "notifications": + return renderNotificationSettings(); + case "preferences": + return renderPreferencesSettings(); + case "account": + return renderAccountSettings(); + default: + return null; + } + }; + + return ( +
+
+ {/* Sidebar */} +
+
+

User Settings

+
+
+ +
+
+ + {/* Main content */} +
+
+

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

+
-
- - +
+ {renderActiveCategory()}
-
-
- - +
+ + +
); -}; +}); export default UserSettings; diff --git a/src/hooks/useKeyboardResize.ts b/src/hooks/useKeyboardResize.ts new file mode 100644 index 00000000..fac24297 --- /dev/null +++ b/src/hooks/useKeyboardResize.ts @@ -0,0 +1,137 @@ +import { platform } from "@tauri-apps/plugin-os"; +import { useEffect } from "react"; + +// Hook to handle keyboard visibility and viewport resizing on mobile platforms +export const useKeyboardResize = () => { + useEffect(() => { + // Check if we're on a mobile device + const isMobile = + /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( + navigator.userAgent, + ) || window.innerWidth <= 768; + + // Only apply this for mobile platforms, but be more permissive than just Tauri + if (!isMobile) { + return; + } + + // If we're in Tauri, check the platform + if ("__TAURI__" in window) { + try { + const currentPlatform = platform(); + if (!["android", "ios"].includes(currentPlatform)) { + return; + } + } catch (error) { + // If platform() fails, continue anyway on mobile devices + console.warn( + "Failed to detect platform, continuing with keyboard handling:", + error, + ); + } + } + + let isKeyboardVisible = false; + let initialViewportHeight = + window.visualViewport?.height || window.innerHeight; + + const handleVisualViewportChange = () => { + if (!window.visualViewport) return; + + const currentHeight = window.visualViewport.height; + const heightDifference = initialViewportHeight - currentHeight; + + // Keyboard is considered visible if the viewport height decreased significantly + const keyboardWasVisible = isKeyboardVisible; + isKeyboardVisible = heightDifference > 150; // Adjust threshold as needed + + // Force a resize event when keyboard state changes + if (keyboardWasVisible !== isKeyboardVisible) { + updateKeyboardState(isKeyboardVisible, heightDifference); + } + }; + + const updateKeyboardState = (visible: boolean, heightDiff: number) => { + // Update CSS custom property for keyboard height + document.documentElement.style.setProperty( + "--keyboard-height", + visible ? `${heightDiff}px` : "0px", + ); + + // Trigger a resize event to force layout recalculation + window.dispatchEvent(new Event("resize")); + + // Small delay to ensure DOM updates are processed + setTimeout(() => { + window.dispatchEvent(new Event("resize")); + }, 50); + }; + + const handleAndroidKeyboardShow = () => { + if (!isKeyboardVisible) { + isKeyboardVisible = true; + const heightDiff = + initialViewportHeight - + (window.visualViewport?.height || window.innerHeight); + updateKeyboardState(true, heightDiff); + } + }; + + const handleAndroidKeyboardHide = () => { + if (isKeyboardVisible) { + isKeyboardVisible = false; + updateKeyboardState(false, 0); + } + }; + + const handleWindowResize = () => { + // Update initial height when window is resized + if (window.visualViewport) { + if (!isKeyboardVisible) { + initialViewportHeight = window.visualViewport.height; + } + } else { + initialViewportHeight = window.innerHeight; + } + }; + + // Use visualViewport API if available (modern browsers) + if (window.visualViewport) { + window.visualViewport.addEventListener( + "resize", + handleVisualViewportChange, + ); + window.visualViewport.addEventListener( + "scroll", + handleVisualViewportChange, + ); + } + + // Listen for native Android keyboard events + window.addEventListener("keyboardDidShow", handleAndroidKeyboardShow); + window.addEventListener("keyboardDidHide", handleAndroidKeyboardHide); + + // Fallback for older browsers or additional handling + window.addEventListener("resize", handleWindowResize); + + // Cleanup + return () => { + if (window.visualViewport) { + window.visualViewport.removeEventListener( + "resize", + handleVisualViewportChange, + ); + window.visualViewport.removeEventListener( + "scroll", + handleVisualViewportChange, + ); + } + window.removeEventListener("keyboardDidShow", handleAndroidKeyboardShow); + window.removeEventListener("keyboardDidHide", handleAndroidKeyboardHide); + window.removeEventListener("resize", handleWindowResize); + + // Reset CSS property + document.documentElement.style.removeProperty("--keyboard-height"); + }; + }, []); +}; diff --git a/src/index.css b/src/index.css index 25ff5df9..8d70375d 100644 --- a/src/index.css +++ b/src/index.css @@ -33,6 +33,7 @@ body { --chart-3: 197 37% 24%; --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; + --keyboard-height: 0px; } .dark { @@ -61,3 +62,26 @@ body { --chart-4: 280 65% 60%; --chart-5: 340 75% 55%; } + +/* Mobile keyboard handling */ +@media (max-width: 768px) { + body { + overflow: hidden; + } + + #root { + height: 100vh; + height: calc(100vh - var(--keyboard-height, 0px)); + transition: height 0.2s ease-in-out; + position: relative; + } +} + +/* Ensure chat layout adjusts properly to keyboard */ +@supports (-webkit-touch-callout: none) { + /* iOS Safari specific adjustments */ + #root { + height: 100vh; + height: -webkit-fill-available; + } +} diff --git a/src/lib/eventGrouping.ts b/src/lib/eventGrouping.ts new file mode 100644 index 00000000..6c8ed5d7 --- /dev/null +++ b/src/lib/eventGrouping.ts @@ -0,0 +1,157 @@ +import type { Message } from "../types"; + +export interface EventGroup { + type: "message" | "eventGroup"; + messages: Message[]; + eventType?: string; + usernames?: string[]; + timestamp: Date; +} + +/** + * Groups consecutive event messages (join, part, quit) into collapsed groups + * while preserving regular messages and other event types as individual items + */ +export function groupConsecutiveEvents(messages: Message[]): EventGroup[] { + const result: EventGroup[] = []; + const collapsibleEventTypes = ["join", "part", "quit"]; + + let i = 0; + while (i < messages.length) { + const currentMessage = messages[i]; + + // If it's not a collapsible event, add as individual message + if (!collapsibleEventTypes.includes(currentMessage.type)) { + result.push({ + type: "message", + messages: [currentMessage], + timestamp: new Date(currentMessage.timestamp), + }); + i++; + continue; + } + + // Start a new event group + const eventGroup: Message[] = [currentMessage]; + const eventType = currentMessage.type; + const startTime = new Date(currentMessage.timestamp); + + // Look ahead for consecutive events of the same type within 5 minutes + let j = i + 1; + while (j < messages.length) { + const nextMessage = messages[j]; + const timeDiff = + new Date(nextMessage.timestamp).getTime() - + new Date(eventGroup[eventGroup.length - 1].timestamp).getTime(); + + // Stop if it's not the same event type, or if there's more than 5 minutes gap + if (nextMessage.type !== eventType || timeDiff > 5 * 60 * 1000) { + break; + } + + eventGroup.push(nextMessage); + j++; + } + + // If we have multiple events of the same type, create a group + if (eventGroup.length > 1) { + const usernames = eventGroup.map((msg) => msg.userId.split("-")[0]); + result.push({ + type: "eventGroup", + messages: eventGroup, + eventType, + usernames, + timestamp: startTime, + }); + } else { + // Single event, add as individual message + result.push({ + type: "message", + messages: [currentMessage], + timestamp: startTime, + }); + } + + i = j; + } + + return result; +} + +/** + * Creates a summary text for collapsed event groups + */ +export function getEventGroupSummary( + eventGroup: EventGroup, + currentUsername?: string, +): string { + if ( + eventGroup.type !== "eventGroup" || + !eventGroup.usernames || + !eventGroup.eventType + ) { + return ""; + } + + const { usernames, eventType } = eventGroup; + const uniqueUsernames = Array.from(new Set(usernames)); + + // Replace current user's username with "You" + const displayNames = uniqueUsernames.map((username) => + username === currentUsername ? "You" : username, + ); + + let action = ""; + switch (eventType) { + case "join": + action = "joined"; + break; + case "part": + action = "left"; + break; + case "quit": + action = "quit"; + break; + default: + action = eventType; + } + + if (displayNames.length === 1) { + const count = usernames.filter((u) => u === uniqueUsernames[0]).length; + return count > 1 + ? `${displayNames[0]} ${action} ${count} times` + : `${displayNames[0]} ${action}`; + } + if (displayNames.length === 2) { + return `${displayNames[0]} and ${displayNames[1]} ${action}`; + } + if (displayNames.length === 3) { + return `${displayNames[0]}, ${displayNames[1]} and ${displayNames[2]} ${action}`; + } + const others = displayNames.length - 2; + return `${displayNames[0]}, ${displayNames[1]} and ${others} others ${action}`; +} + +/** + * Creates detailed tooltip information for event groups + */ +export function getEventGroupTooltip(eventGroup: EventGroup): string { + if (eventGroup.type !== "eventGroup" || !eventGroup.usernames) { + return ""; + } + + const userCounts = eventGroup.usernames.reduce( + (acc, username) => { + acc[username] = (acc[username] || 0) + 1; + return acc; + }, + {} as Record, + ); + + return Object.entries(userCounts) + .map( + ([username, count]) => + `${username}: ${count} time${count > 1 ? "s" : ""}`, + ) + .join("\n"); +} diff --git a/src/lib/ignoreUtils.ts b/src/lib/ignoreUtils.ts new file mode 100644 index 00000000..bae86871 --- /dev/null +++ b/src/lib/ignoreUtils.ts @@ -0,0 +1,126 @@ +/** + * Utility functions for handling ignore list patterns and matching + */ + +/** + * Check if a hostmask (nick!user@host) matches an ignore pattern + * Supports patterns like: + * - nick!*@* (ignore by nick) + * - *!user@* (ignore by user) + * - *!*@host (ignore by host) + * - nick!user@host (exact match) + * - *!*@*.domain.com (wildcard matching) + */ +export function matchesIgnorePattern( + hostmask: string, + pattern: string, +): boolean { + // Normalize both strings to lowercase for case-insensitive matching + const normalizedHostmask = hostmask.toLowerCase(); + const normalizedPattern = pattern.toLowerCase(); + + // Convert IRC wildcard pattern to regex + // * matches any number of characters (including none) + // ? matches exactly one character + const regexPattern = normalizedPattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") // Escape regex special chars except * and ? + .replace(/\*/g, ".*") // Convert * to .* + .replace(/\?/g, "."); // Convert ? to . + + try { + const regex = new RegExp(`^${regexPattern}$`); + return regex.test(normalizedHostmask); + } catch (error) { + console.warn(`Invalid ignore pattern: ${pattern}`, error); + return false; + } +} + +/** + * Check if a user should be ignored based on the ignore list + * @param nick - User's nickname + * @param user - User's username (optional) + * @param host - User's hostname (optional) + * @param ignoreList - Array of ignore patterns + */ +export function isUserIgnored( + nick: string, + user?: string, + host?: string, + ignoreList: string[] = [], +): boolean { + if (ignoreList.length === 0) return false; + + // Build possible hostmask variations to check + const hostmasks: string[] = []; + + // Full hostmask if all parts available + if (user && host) { + hostmasks.push(`${nick}!${user}@${host}`); + } + + // Partial hostmasks + if (user) { + hostmasks.push(`${nick}!${user}@*`); + } + if (host) { + hostmasks.push(`${nick}!*@${host}`); + } + + // Nick-only + hostmasks.push(`${nick}!*@*`); + + // Check each hostmask against all ignore patterns + for (const hostmask of hostmasks) { + for (const pattern of ignoreList) { + if (matchesIgnorePattern(hostmask, pattern)) { + return true; + } + } + } + + return false; +} + +/** + * Validate an ignore pattern format + */ +export function isValidIgnorePattern(pattern: string): boolean { + if (!pattern || pattern.trim().length === 0) { + return false; + } + + const trimmed = pattern.trim(); + + // Must contain at least one ! and one @ + const exclamationCount = (trimmed.match(/!/g) || []).length; + const atCount = (trimmed.match(/@/g) || []).length; + + if (exclamationCount !== 1 || atCount !== 1) { + return false; + } + + // Should be in format nick!user@host + const parts = trimmed.split("!"); + if (parts.length !== 2) return false; + + const [nick, userHost] = parts; + const userHostParts = userHost.split("@"); + if (userHostParts.length !== 2) return false; + + const [user, host] = userHostParts; + + // All parts should have at least some content (even if it's just *) + return nick.length > 0 && user.length > 0 && host.length > 0; +} + +/** + * Create an ignore pattern from nick, user, and host components + */ +export function createIgnorePattern( + nick?: string, + user?: string, + host?: string, +): string { + return `${nick || "*"}!${user || "*"}@${host || "*"}`; +} diff --git a/src/lib/ircClient.ts b/src/lib/ircClient.ts index 55ef9993..df3312b5 100644 --- a/src/lib/ircClient.ts +++ b/src/lib/ircClient.ts @@ -22,8 +22,8 @@ export interface EventMap { oldNick: string; newNick: string; }; - QUIT: BaseUserActionEvent & { reason: string }; - JOIN: BaseUserActionEvent & { channelName: string }; + QUIT: BaseUserActionEvent & { reason: string; batchTag?: string }; + JOIN: BaseUserActionEvent & { channelName: string; batchTag?: string }; PART: BaseUserActionEvent & { channelName: string; reason?: string; @@ -38,6 +38,10 @@ export interface EventMap { channelName: string; }; USERMSG: BaseMessageEvent; + CHANNNOTICE: BaseMessageEvent & { + channelName: string; + }; + USERNOTICE: BaseMessageEvent; TAGMSG: EventWithTags & { sender: string; channelName: string; @@ -64,8 +68,17 @@ export interface EventMap { METADATA_UNSUBOK: BaseIRCEvent & { keys: string[] }; METADATA_SUBS: BaseIRCEvent & { keys: string[] }; METADATA_SYNCLATER: BaseIRCEvent & { target: string; retryAfter?: number }; - BATCH_START: BaseIRCEvent & { batchId: string; type: string }; + 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; @@ -142,6 +155,31 @@ export interface EventMap { target: string; message: string; }; + AWAY: { + serverId: string; + username: string; + awayMessage?: 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; + }; } type EventKey = keyof EventMap; @@ -151,11 +189,29 @@ export class IRCClient { private sockets: Map = new Map(); private servers: Map = new Map(); private nicks: Map = new Map(); - private currentUser: User | null = null; + 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 activeBatches: Map< + string, + Map< + string, + { + type: string; + parameters?: string[]; + messages: string[]; + concatFlags?: boolean[]; + sender?: string; + messageIds?: string[]; + batchMsgId?: string; + } + > + > = new Map(); // Track active batches per server private eventCallbacks: { [K in EventKey]?: EventCallback[]; @@ -164,6 +220,7 @@ export class IRCClient { public version = __APP_VERSION__; connect( + name: string, host: string, port: number, nickname: string, @@ -203,9 +260,12 @@ export class IRCClient { } // Create server object immediately and add to servers map + // Use provided name, default to host if name is empty + const finalName = name?.trim() || host; + const server: Server = { id: serverId || uuidv4(), - name: host, + name: finalName, host, port, channels: [], @@ -216,12 +276,33 @@ export class IRCClient { this.servers.set(server.id, server); this.sockets.set(server.id, socket); this.saslEnabled.set(server.id, !!_saslAccountName); - this.currentUser = { + console.log( + `[SASL] SASL enabled for ${server.id}: ${!!_saslAccountName}`, + ); + console.log(`[SASL] SASL account name: ${_saslAccountName}`); + console.log(`[SASL] SASL password provided: ${!!_saslPassword}`); + + // Store SASL credentials if provided + if (_saslAccountName && _saslPassword) { + this.saslCredentials.set(server.id, { + username: _saslAccountName, + password: _saslPassword, + }); + console.log( + `[SASL] Stored SASL credentials for ${server.id}: ${_saslAccountName}`, + ); + } else { + console.log( + `[SASL] No SASL credentials stored for ${server.id} - account: ${_saslAccountName}, password: ${!!_saslPassword}`, + ); + } + + this.currentUsers.set(server.id, { id: uuidv4(), username: nickname, isOnline: true, status: "online", - }; + }); this.nicks.set(server.id, nickname); socket.onopen = () => { @@ -294,8 +375,8 @@ export class IRCClient { sendRaw(serverId: string, command: string): void { const socket = this.sockets.get(serverId); if (socket && socket.readyState === WebSocket.OPEN) { - // Log metadata commands but not sensitive commands - if (command.startsWith("METADATA")) { + // Log metadata and command-related outgoing messages for debugging + if (command.startsWith("METADATA") || command.startsWith("/")) { console.log(`[IRC] Sending: ${command}`); } socket.send(command); @@ -324,8 +405,17 @@ export class IRCClient { isMentioned: false, messages: [], users: [], + isLoadingHistory: true, // Start in loading state }; server.channels.push(channel); + + // Trigger event to notify store that history loading started + this.triggerEvent("CHATHISTORY_LOADING", { + serverId, + channelName, + isLoading: true, + }); + return channel; } throw new Error(`Server with ID ${serverId} not found`); @@ -344,7 +434,74 @@ export class IRCClient { 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`); - this.sendRaw(serverId, `PRIVMSG ${channel.name} :${content}`); + + // 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}`); + } + } + + 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 { @@ -397,6 +554,10 @@ export class IRCClient { 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(" "); @@ -414,11 +575,15 @@ export class IRCClient { value?: string, visibility?: string, ): void { - const visibilityStr = visibility ? ` ${visibility}` : ""; + // 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 - ? `METADATA * SET ${key} :${value}` - : `METADATA * SET ${key} :`; + value !== undefined && value !== "" + ? `METADATA ${actualTarget} SET ${key} :${value}` + : `METADATA ${actualTarget} SET ${key}`; console.log(`[IRC] Sending metadata SET command: ${command}`); this.sendRaw(serverId, command); } @@ -428,8 +593,12 @@ export class IRCClient { } metadataSub(serverId: string, keys: string[]): void { - const keysStr = keys.join(" "); - this.sendRaw(serverId, `METADATA * SUB ${keysStr}`); + // Send individual SUB commands for each key to avoid parsing issues + keys.forEach((key) => { + const command = `METADATA * SUB ${key}`; + console.log(`[IRC] Sending metadata subscription command: ${command}`); + this.sendRaw(serverId, command); + }); } metadataUnsub(serverId: string, keys: string[]): void { @@ -472,23 +641,57 @@ export class IRCClient { } private handleMessage(data: string, serverId: string): void { - console.log(`IRC Message from serverId=${serverId}:`, data); - const lines = data.split("\r\n"); for (let line of lines) { let mtags: Record | undefined; let source: string; - const parv = []; + const parv: string[] = []; let i = 0; let l: string[]; line = line.trim(); - l = line.split(" ") ?? line; - if (l[i][0] === "@") { - mtags = parseMessageTags(l[i]); - i++; + // Skip empty lines + if (!line) continue; + + // Debug: Log ALL lines that contain CAP to see if CAP ACK is even being processed + if (line.includes("CAP")) { + console.log(`[HANDLE-MSG] Processing line: '${line}'`); + } + + // Debug: Log all incoming IRC messages + console.log(`[IRC] ${serverId}: ${line}`); + + // Handle message tags first, before splitting on trailing parameter + let lineAfterTags = line; + if (line[0] === "@") { + const spaceIndex = line.indexOf(" "); + if (spaceIndex !== -1) { + console.log( + `[MTAGS] Parsing message tags from: '${line.substring(0, spaceIndex)}', original line length: ${line.length}`, + ); + mtags = parseMessageTags(line.substring(0, spaceIndex)); + lineAfterTags = line.substring(spaceIndex + 1); + console.log( + `[MTAGS] After parsing tags, remaining line: '${lineAfterTags}'`, + ); + } + } + + // 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); @@ -508,6 +711,40 @@ export class IRCClient { 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")) { + console.log(`[DEBUG-ALWAYS] Line: '${line}'`); + console.log(`[DEBUG-ALWAYS] Command detected: '${command}'`); + console.log(`[DEBUG-ALWAYS] l array: ${JSON.stringify(l)}`); + console.log(`[DEBUG-ALWAYS] i when command detected: ${i - 1}`); + console.log(`[DEBUG-ALWAYS] mtags: ${JSON.stringify(mtags)}`); + console.log(`[DEBUG-ALWAYS] source: '${source}'`); + } + + // Debug: log command and parv for CAP messages + if (command === "CAP" || line.includes("CAP")) { + console.log( + `[DEBUG] Command: '${command}', Source: '${source}', Parv: ${JSON.stringify(parv)}, Trailing: '${trailing}'`, + ); + } + + // Debug: for message tags, show what l array looks like + if (line.includes("@time") && line.includes("CAP")) { + console.log(`[DEBUG-TAGS] Original line: '${line}'`); + console.log(`[DEBUG-TAGS] mainPart: '${mainPart}'`); + console.log(`[DEBUG-TAGS] trailing: '${trailing}'`); + console.log(`[DEBUG-TAGS] l array: ${JSON.stringify(l)}`); + console.log( + `[DEBUG-TAGS] i when command parsed: ${i - 1}, command: '${command}'`, + ); + } + const parc = parv.length; if (command === "PING") { @@ -516,19 +753,41 @@ export class IRCClient { console.log(`PONG sent to server ${serverId} with key ${key}`); } else if (command === "001") { const serverName = source; - const nickname = parv.join(" "); + 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 }); } else if (command === "NICK") { console.log("triggered nickchange"); const oldNick = getNickFromNuh(source); - const newNick = parv[0]; + 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 - if (this.currentUser) { - this.currentUser.username = newNick; + // Update current user's username for this server + const currentUser = this.currentUsers.get(serverId); + if (currentUser) { + this.currentUsers.set(serverId, { + ...currentUser, + username: newNick, + }); } } @@ -542,11 +801,32 @@ export class IRCClient { } else if (command === "QUIT") { const username = getNickFromNuh(source); const reason = parv.join(" "); - this.triggerEvent("QUIT", { serverId, username, reason }); + 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 === "JOIN") { const username = getNickFromNuh(source); const channelName = parv[0][0] === ":" ? parv[0].substring(1) : parv[0]; - this.triggerEvent("JOIN", { serverId, username, channelName }); + this.triggerEvent("JOIN", { + serverId, + username, + channelName, + batchTag: mtags?.batch, + }); } else if (command === "PART") { const username = getNickFromNuh(source); const channelName = parv[0]; @@ -578,8 +858,58 @@ export class IRCClient { const isChannel = target.startsWith("#"); const sender = getNickFromNuh(source); - parv[0] = ""; - const message = parv.join(" ").trim().substring(1); + // 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); + + console.log( + `[IRC] Adding message to batch ${batchId}: mtags=`, + mtags, + `msgid=${mtags?.msgid}`, + ); + + // Store sender from the first message + if (!batch.sender) { + batch.sender = sender; + } + + // Track message IDs for redaction + if (!batch.messageIds) { + batch.messageIds = []; + } + if (mtags?.msgid) { + batch.messageIds.push(mtags.msgid); + console.log( + `[IRC] Added msgid ${mtags.msgid} to batch ${batchId}`, + ); + } else { + console.log( + `[IRC] No msgid found for message in batch ${batchId}`, + ); + } + + // Track if this message has the concat flag + if (!batch.concatFlags) { + batch.concatFlags = []; + } + const hasMultilineConcat = + mtags && mtags["draft/multiline-concat"] !== undefined; + batch.concatFlags.push(!!hasMultilineConcat); + + return; // Don't trigger individual message event, wait for batch completion + } + } if (isChannel) { const channelName = target; @@ -600,6 +930,33 @@ export class IRCClient { 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(":") @@ -630,7 +987,7 @@ export class IRCClient { const user = getNickFromNuh(source); const oldName = parv[0]; const newName = parv[1]; - const reason = parv.slice(2).join(" ").substring(1); // Remove leading : + const reason = parv.slice(2).join(" "); // No need to remove leading : anymore this.triggerEvent("RENAME", { serverId, oldName, @@ -640,7 +997,7 @@ export class IRCClient { }); } else if (command === "SETNAME") { const user = getNickFromNuh(source); - const realname = parv.join(" ").substring(1); // Remove leading : + const realname = parv.join(" "); // No need to remove leading : anymore this.triggerEvent("SETNAME", { serverId, user, @@ -660,10 +1017,22 @@ export class IRCClient { users: newUsers, }); } 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++; + if (parv[i] === "*") { + console.log(`[CAP] Skipping * at position ${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" && @@ -677,17 +1046,36 @@ export class IRCClient { } const isFinal = subcommand === "LS" && parv[i] !== "*"; if (parv[i] === "*") i++; - parv[i] = parv[i].substring(1); // trim the ":" lol - while (parv[i]) { - caps += parv[i++]; - if (parv[i]) caps += " "; + + // 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.triggerEvent("CAP ACK", { serverId, cliCaps: caps }); - else if (subcommand === "NEW") this.onCapNew(serverId, caps); + else if (subcommand === "ACK") { + console.log(`[CAP ACK] Received for ${serverId}: ${caps}`); + this.onCapAck(serverId, caps); + } else if (subcommand === "NAK") { + console.log( + `[CAP NAK] Server rejected capabilities for ${serverId}: ${caps}`, + ); + // Server rejected some capabilities, but we should still end CAP negotiation + this.sendRaw(serverId, "CAP END"); + } else if (subcommand === "NEW") this.onCapNew(serverId, caps); else if (subcommand === "DEL") this.onCapDel(serverId, caps); + else { + console.log( + `[CAP] Unknown subcommand '${subcommand}' for ${serverId}: ${caps}`, + ); + } } else if (command === "005") { const capabilities = parseIsupport(parv.join(" ")); console.log("ISUPPORT capabilities:", capabilities); @@ -707,13 +1095,134 @@ export class IRCClient { } else if (command === "AUTHENTICATE") { const param = parv.join(" "); this.triggerEvent("AUTHENTICATE", { serverId, param }); + + // Handle SASL PLAIN authentication + if (param === "+") { + const creds = this.saslCredentials.get(serverId); + if (creds) { + console.log(`Sending SASL PLAIN credentials for ${serverId}`); + this.sendSaslPlain(serverId, creds.username, creds.password); + } + } + } 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); + console.log( + `[IRC] Starting batch: id=${batchId}, type=${batchType}, params=${parameters.join(" ")}`, + ); + + // 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: [], + batchMsgId: mtags?.msgid, // Store the msgid from the BATCH command itself + }); + + this.triggerEvent("BATCH_START", { + serverId, + batchId, + type: batchType, + parameters, + }); + } else { + console.log(`[IRC] Ending batch: id=${batchId}`); + + // 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"; + + console.log( + `[IRC] Processing multiline batch: target=${target}, sender=${sender}, messages=${batch.messages.length}`, + ); + + // Combine messages, handling draft/multiline-concat tags + let combinedMessage = ""; + batch.messages.forEach((message, index) => { + const wasConcat = batch.concatFlags?.[index]; + console.log( + `[IRC] Message ${index}: concat=${wasConcat}, content="${message}"`, + ); + + if (index === 0) { + combinedMessage = message; + } else { + // Check if this message was tagged with draft/multiline-concat + if (wasConcat) { + // Concatenate directly without separator + console.log("[IRC] Concatenating without separator"); + combinedMessage += message; + } else { + // Join with newline (normal multiline) + console.log("[IRC] Adding newline separator"); + combinedMessage += `\n${message}`; + } + } + }); + + console.log( + `[IRC] Triggering MULTILINE_MESSAGE for batch ${batchId}, combined message length: ${combinedMessage.length}, batchMsgId: ${batch.batchMsgId}`, + ); + 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: getTimestampFromTags(mtags), + }); + } + + // Clean up batch tracking + serverBatches?.delete(batchId); + + this.triggerEvent("BATCH_END", { + serverId, + batchId, + }); + } } else if (command === "METADATA") { - const target = parv[0]; - const key = parv[1]; - const visibility = parv[2]; - const value = parv.slice(3).join(" ").substring(1); // Remove leading : + // 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] : ""; + console.log( - `[IRC] Received METADATA: target=${target}, key=${key}, visibility=${visibility}, value=${value}`, + `[IRC] Received METADATA: target=${target}, key=${key}, visibility=${visibility}, value=${value}, optionalParams=${optionalParams.join(" ")}`, ); this.triggerEvent("METADATA", { serverId, @@ -728,7 +1237,7 @@ export class IRCClient { const target = parv[0]; const key = parv[1]; const visibility = parv[2]; - const value = parv.slice(3).join(" ").substring(1); + const value = parv.slice(3).join(" "); // No need to remove leading : anymore this.triggerEvent("METADATA_WHOIS", { serverId, target, @@ -738,18 +1247,18 @@ export class IRCClient { }); } else if (command === "761") { // RPL_KEYVALUE - // RPL_KEYVALUE : - // Note: Server sometimes sends target twice, so detect and handle this - const target = parv[0]; - let key = parv[1]; - let visibility = parv[2]; - let valueStartIndex = 3; - - // If target is duplicated (server bug), skip the duplicate - if (parv[0] === parv[1] && parv.length > 4) { - key = parv[2]; - visibility = parv[3]; - valueStartIndex = 4; + // 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(" "); @@ -771,18 +1280,31 @@ export class IRCClient { this.triggerEvent("METADATA_KEYNOTSET", { serverId, target, key }); } else if (command === "770") { // RPL_METADATASUBOK - // RPL_METADATASUBOK [ ...] - const keys = parv.slice(0); + // Format: 770 [ ...] + const target = parv[0]; + const keys = parv + .slice(1) + .map((key) => (key.startsWith(":") ? key.substring(1) : key)); + console.log( + `[IRC] Received METADATA_SUBOK for target ${target}, keys:`, + keys, + ); this.triggerEvent("METADATA_SUBOK", { serverId, keys }); } else if (command === "771") { // RPL_METADATAUNSUBOK - // RPL_METADATAUNSUBOK [ ...] - const keys = parv.slice(0); + // 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 - // RPL_METADATASUBS [ ...] - const keys = parv.slice(0); + // 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 @@ -795,23 +1317,42 @@ export class IRCClient { retryAfter, }); } else if (command === "FAIL" && parv[0] === "METADATA") { + // FAIL METADATA [] [] [] :[] // ERR_METADATATOOMANY, ERR_METADATATARGETINVALID, ERR_METADATANOACCESS, ERR_METADATANOKEY, ERR_METADATARATELIMITED - const subcommand = parv[0]; - const code = parv[1]; + 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 (parv[2]) target = parv[2]; - if (parv[3]) key = parv[3]; - if (parv[4] && code === "RATE_LIMITED") { - retryAfter = Number.parseInt(parv[4], 10); + + if (paramCount > 3) target = parv[3]; + if (paramCount > 4) key = parv[4]; + if (paramCount > 5 && code === "RATE_LIMITED") { + retryAfter = Number.parseInt(parv[5], 10); } + console.log( - `[IRC] Received METADATA FAIL: subcommand=${parv[1]}, code=${code}, target=${target}, key=${key}, retryAfter=${retryAfter}`, + `[IRC] Received METADATA FAIL: subcommand=${subcommand}, code=${code}, target=${target}, key=${key}, retryAfter=${retryAfter}, message=${errorMessage}`, ); this.triggerEvent("METADATA_FAIL", { serverId, - subcommand: parv[1], + subcommand, code, target, key, @@ -821,7 +1362,7 @@ export class IRCClient { // RPL_LIST: : const channelName = parv[1]; const userCount = parv[2] ? Number.parseInt(parv[2], 10) : 0; - const topic = parv.slice(3).join(" ").substring(1); // Remove leading : + const topic = parv.slice(3).join(" "); // No need to remove leading : anymore this.triggerEvent("LIST_CHANNEL", { serverId, channel: channelName, @@ -840,7 +1381,7 @@ export class IRCClient { const nick = parv[5]; const flags = parv[6]; const hopcount = parv[7]; - const realname = parv.slice(8).join(" ").substring(1); + const realname = parv.slice(8).join(" "); // No need to remove leading : anymore this.triggerEvent("WHO_REPLY", { serverId, channel, @@ -852,6 +1393,22 @@ export class IRCClient { hopcount, realname, }); + } 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 === "315") { // RPL_ENDOFWHO const mask = parv[1]; @@ -860,8 +1417,74 @@ export class IRCClient { // RPL_WHOISBOT: : const nick = parv[0]; const target = parv[1]; - const message = parv.slice(2).join(" ").substring(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" || + command === "901" || + command === "902" || + command === "903" + ) { + // SASL authentication successful + const message = parv.slice(2).join(" "); + console.log( + `SASL authentication successful for ${serverId}: ${message}`, + ); + // Finish capability negotiation + this.sendRaw(serverId, "CAP END"); + } else if ( + command === "904" || + command === "905" || + command === "906" || + command === "907" + ) { + // SASL authentication failed + const message = parv.slice(2).join(" "); + console.log(`SASL authentication failed for ${serverId}: ${message}`); + // Still finish capability negotiation even if SASL failed + this.sendRaw(serverId, "CAP END"); + } 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]; @@ -985,6 +1608,9 @@ export class IRCClient { "draft/metadata-2", "draft/message-redaction", "draft/account-registration", + "batch", + "draft/multiline", + "znc.in/playback", ]; let accumulated = this.capLsAccumulated.get(serverId); @@ -1008,25 +1634,73 @@ export class IRCClient { if (isFinal) { // Now request the caps we want from the accumulated list - let toRequest = "CAP REQ :"; + const capsToRequest: string[] = []; const saslEnabled = this.saslEnabled.get(serverId) ?? false; for (const cap of accumulated) { if ( (ourCaps.includes(cap) || cap.startsWith("draft/metadata")) && (cap !== "sasl" || saslEnabled) ) { - if (toRequest.length + cap.length + 1 > 400) { - this.sendRaw(serverId, toRequest); - toRequest = "CAP REQ :"; - } - toRequest += `${cap} `; + capsToRequest.push(cap); console.log(`Requesting capability: ${cap}`); } } - if (toRequest.length > 9) { - this.sendRaw(serverId, toRequest); - if (toRequest.includes("draft/extended-isupport")) + + 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(" ")}`; + console.log( + `Sending CAP REQ batch ${batchCount + 1} (${reqMessage.length} chars): ${reqMessage}`, + ); + 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(" ")}`; + console.log( + `Sending CAP REQ batch ${batchCount + 1} (${reqMessage.length} chars): ${reqMessage}`, + ); + this.sendRaw(serverId, reqMessage); + batchCount++; + } + + // Track how many CAP REQ batches we sent + this.pendingCapReqs.set(serverId, batchCount); + console.log(`Sent ${batchCount} CAP REQ batches for ${serverId}`); + + // Set a timeout to send CAP END if server doesn't respond + setTimeout(() => { + if (this.pendingCapReqs.has(serverId)) { + console.log( + `[CAP] Timeout waiting for CAP ACK from ${serverId}, sending CAP END`, + ); + this.pendingCapReqs.delete(serverId); + this.sendRaw(serverId, "CAP END"); + } + }, 5000); // 5 second timeout + + if (capsToRequest.includes("draft/extended-isupport")) { this.sendRaw(serverId, "ISUPPORT"); + } } console.log( `Server ${serverId} supports capabilities: ${Array.from(accumulated).join(" ")}`, @@ -1063,6 +1737,39 @@ export class IRCClient { } } + onCapAck(serverId: string, cliCaps: string): void { + console.log(`[CAP ACK] onCapAck called for ${serverId}: ${cliCaps}`); + + // 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; + console.log( + `[CAP ACK] ${serverId}: ${pendingCount} -> ${newCount} pending batches`, + ); + + 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 + console.log( + `[CAP ACK] All capability batches acknowledged for ${serverId}, SASL handled by store`, + ); + } else { + this.pendingCapReqs.set(serverId, newCount); + } + } else { + console.log( + `[CAP ACK] Warning: Received CAP ACK for ${serverId} but no pending requests`, + ); + } + } + on(event: K, callback: EventCallback): void { if (!this.eventCallbacks[event]) { this.eventCallbacks[event] = []; @@ -1091,8 +1798,10 @@ export class IRCClient { return Array.from(this.servers.values()); } - getCurrentUser(): User | null { - return this.currentUser; + 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[] { diff --git a/src/lib/ircUtils.tsx b/src/lib/ircUtils.tsx index 9005fdef..70e0f8b3 100644 --- a/src/lib/ircUtils.tsx +++ b/src/lib/ircUtils.tsx @@ -43,6 +43,23 @@ export function parseMessageTags(tags: string): Record { return parsedTags; } +/** + * Check if a user is verified based on the account tag matching their nickname. + * According to IRCv3 account-tag spec, if the account tag matches the sender's nick + * (case-insensitively), the user is authenticated to that account. + */ +export function isUserVerified( + senderNick: string, + messageTags?: Record, +): boolean { + if (!messageTags?.account) { + return false; + } + + // Case-insensitive comparison as per the requirement + return senderNick.toLowerCase() === messageTags.account.toLowerCase(); +} + export function parseIsupport(tokens: string): Record { const tokenMap: Record = {}; const tokenPairs = tokens.split(" "); diff --git a/src/lib/notificationSounds.ts b/src/lib/notificationSounds.ts new file mode 100644 index 00000000..21fce6ec --- /dev/null +++ b/src/lib/notificationSounds.ts @@ -0,0 +1,110 @@ +/** + * Notification sound utilities for playing audio notifications + */ + +// Play notification sound based on current settings +export const playNotificationSound = async (globalSettings: { + enableNotificationSounds: boolean; + notificationSound: string; +}) => { + // Check if notification sounds are enabled + if (!globalSettings.enableNotificationSounds) { + return; + } + + try { + let audioSrc: string; + + if (globalSettings.notificationSound) { + // Play custom uploaded sound from URL string + audioSrc = globalSettings.notificationSound; + } else { + // Play default notification sound using Web Audio API + const AudioContextClass = + window.AudioContext || + (window as unknown as { webkitAudioContext: typeof AudioContext }) + .webkitAudioContext; + const audioContext = new AudioContextClass(); + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + + oscillator.frequency.setValueAtTime(800, audioContext.currentTime); + oscillator.type = "sine"; + + gainNode.gain.setValueAtTime(0, audioContext.currentTime); + gainNode.gain.linearRampToValueAtTime( + 0.1, + audioContext.currentTime + 0.01, + ); + gainNode.gain.exponentialRampToValueAtTime( + 0.01, + audioContext.currentTime + 0.5, + ); + + oscillator.start(audioContext.currentTime); + oscillator.stop(audioContext.currentTime + 0.5); + return; + } + + const audio = new Audio(audioSrc); + audio.volume = 0.3; // Set reasonable volume for notifications + await audio.play(); + } catch (error) { + console.error("Failed to play notification sound:", error); + // Fallback to default browser notification sound or do nothing + } +}; + +// Check if message should trigger a notification sound +export const shouldPlayNotificationSound = ( + message: { userId: string; content: string; type: string }, + currentUser: { username: string } | null, + globalSettings: { + enableHighlights: boolean; + enableNotificationSounds: boolean; + customMentions: string[]; + }, +): boolean => { + // Don't play sound if notification sounds are disabled + if (!globalSettings.enableNotificationSounds) { + return false; + } + + // Don't play sound for our own messages + if (currentUser && message.userId === currentUser.username) { + return false; + } + + // Only check highlights for actual messages (PRIVMSG) and notices (NOTICE) + const isUserMessage = message.type === "message"; + const isSystemMessage = ["system", "join", "part", "quit", "nick"].includes( + message.type, + ); + + if (!isUserMessage || isSystemMessage) { + return false; // Don't trigger sounds for system messages + } + + // If highlights are enabled, check for mentions + if (globalSettings.enableHighlights && currentUser) { + const content = message.content.toLowerCase(); + + // Check for username mention + const usernameMention = content.includes( + currentUser.username.toLowerCase(), + ); + + // Check for custom mentions + const customMention = globalSettings.customMentions.some( + (mention) => mention.trim() && content.includes(mention.toLowerCase()), + ); + + return usernameMention || customMention; + } + + // If highlights are disabled, play sound for all user messages (except our own) + return true; +}; diff --git a/src/store/index.ts b/src/store/index.ts index ebd67df7..57a033bc 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,6 +1,11 @@ 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 { registerAllProtocolHandlers } from "../protocol"; import type { Channel, @@ -13,6 +18,7 @@ import type { const LOCAL_STORAGE_SERVERS_KEY = "savedServers"; const LOCAL_STORAGE_METADATA_KEY = "serverMetadata"; +const LOCAL_STORAGE_SETTINGS_KEY = "globalSettings"; // Type for saved metadata structure: serverId -> target -> key -> metadata type SavedMetadata = Record< @@ -20,6 +26,44 @@ type SavedMetadata = Record< Record> >; +// Types for batch event processing +interface JoinBatchEvent { + type: "JOIN"; + data: { + serverId: string; + username: string; + channelName: string; + }; +} + +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; +} + export const getChannelMessages = (serverId: string, channelId: string) => { const state = useStore.getState(); const key = `${serverId}-${channelId}`; @@ -49,18 +93,49 @@ 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)); +} + // Check if a server supports metadata function serverSupportsMetadata(serverId: string): boolean { const state = useStore.getState(); const server = state.servers.find((s) => s.id === serverId); - return ( + const supports = server?.capabilities?.some( (cap) => cap === "draft/metadata-2" || cap.startsWith("draft/metadata"), - ) ?? false + ) ?? false; + console.log( + `[SERVER_CAPS] Server ${serverId} capabilities:`, + server?.capabilities, + ); + console.log(`[SERVER_CAPS] Server ${serverId} supports metadata:`, supports); + 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; + console.log( + `[SERVER_CAPS] Server ${serverId} supports draft/multiline:`, + supports, ); + return supports; } -export { serverSupportsMetadata }; +export { serverSupportsMetadata, serverSupportsMultiline }; function saveServersToLocalStorage(servers: ServerConfig[]) { localStorage.setItem(LOCAL_STORAGE_SERVERS_KEY, JSON.stringify(servers)); @@ -133,6 +208,59 @@ function restoreServerMetadata(serverId: string) { }); } +// 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) { + console.log(`[METADATA_FETCH] No nickname found for server ${serverId}`); + resolve(); + return; + } + + console.log( + `[METADATA_FETCH] Fetching metadata for ${nickname} on server ${serverId}`, + ); + + // 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(() => { + console.log( + `[METADATA_FETCH] Metadata fetch completed for server ${serverId}`, + ); + useStore.setState((state) => ({ + metadataFetchInProgress: { + ...state.metadataFetchInProgress, + [serverId]: false, + }, + })); + resolve(); + }, 1000); + }); +} + interface UIState { selectedServerId: string | null; selectedChannelId: string | null; @@ -160,6 +288,27 @@ interface UIState { interface GlobalSettings { enableNotifications: boolean; + notificationSound: string; + enableNotificationSounds: boolean; + enableHighlights: boolean; + sendTypingNotifications: boolean; + // Event visibility settings + showEvents: boolean; + showNickChanges: boolean; + showJoinsParts: boolean; + showQuits: 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; } export interface AppState { @@ -199,6 +348,8 @@ export interface AppState { }[]; } >; // batchId -> batch info + activeBatches: Record>; // serverId -> batchId -> batch info + metadataFetchInProgress: Record; // serverId -> is fetching own metadata // Account registration state pendingRegistration: { serverId: string; @@ -211,6 +362,7 @@ export interface AppState { globalSettings: GlobalSettings; // Actions connect: ( + name: string, host: string, port: number, nickname: string, @@ -259,6 +411,7 @@ export interface AppState { 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"; @@ -301,6 +454,11 @@ export interface AppState { ) => void; hideContextMenu: () => void; setMobileViewActiveColumn: (column: layoutColumn) => void; + // Settings actions + updateGlobalSettings: (settings: Partial) => void; + // Ignore list actions + addToIgnoreList: (pattern: string) => void; + removeFromIgnoreList: (pattern: string) => void; // Metadata actions metadataGet: (serverId: string, target: string, keys: string[]) => void; metadataList: (serverId: string, target: string) => void; @@ -332,6 +490,8 @@ const useStore = create((set, get) => ({ listingInProgress: {}, metadataSubscriptions: {}, metadataBatches: {}, + activeBatches: {}, + metadataFetchInProgress: {}, pendingRegistration: null, selectedServerId: null, @@ -362,10 +522,33 @@ const useStore = create((set, get) => ({ }, globalSettings: { enableNotifications: false, + notificationSound: "", + enableNotificationSounds: true, + enableHighlights: true, + sendTypingNotifications: true, + // Event visibility settings (enabled by default) + showEvents: true, + showNickChanges: true, + showJoinsParts: true, + showQuits: true, + // Custom mentions + customMentions: [], + // Ignore list + ignoreList: ["HistServ!*@*"], + // Hosted chat mode settings + nickname: "", + accountName: "", + accountPassword: "", + // Multiline settings + enableMultilineInput: true, + multilineOnShiftEnter: true, + autoFallbackToSingleLine: true, + ...loadSavedGlobalSettings(), // Load saved settings from localStorage }, // IRC client actions connect: async ( + name, host, port, nickname, @@ -397,6 +580,7 @@ const useStore = create((set, get) => ({ ); const server = await ircClient.connect( + name, host, port, nickname, @@ -418,6 +602,7 @@ const useStore = create((set, get) => ({ ); updatedServers.push({ id: server.id, // Include the server ID here + name: server.name, // Save the server name host, port, nickname, @@ -435,13 +620,11 @@ const useStore = create((set, get) => ({ ); if (alreadyExists) { return { - currentUser: ircClient.getCurrentUser(), isConnecting: false, }; } return { servers: [...state.servers, server], - currentUser: ircClient.getCurrentUser(), isConnecting: false, }; }); @@ -644,6 +827,10 @@ const useStore = create((set, get) => ({ ircClient.setName(serverId, realname); }, + changeNick: (serverId, newNick) => { + ircClient.changeNick(serverId, newNick); + }, + addMessage: (message) => { set((state) => { const channelKey = `${message.serverId}-${message.channelId}`; @@ -868,8 +1055,11 @@ const useStore = create((set, get) => ({ 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 (state.currentUser?.username === username) { + if (currentUser?.username === username) { return {}; } @@ -961,6 +1151,7 @@ const useStore = create((set, get) => ({ connectToSavedServers: async () => { const savedServers = loadSavedServers(); for (const { + name, host, port, nickname, @@ -972,6 +1163,7 @@ const useStore = create((set, get) => ({ } of savedServers) { try { const server = await get().connect( + name || host, // Use saved name, default to host host, port, nickname, @@ -1000,6 +1192,11 @@ const useStore = create((set, get) => ({ ); 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, @@ -1076,12 +1273,22 @@ const useStore = create((set, get) => ({ 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: openState ? "memberList" : "chatView", + mobileViewActiveColumn: shouldUpdateMobileColumn + ? openState + ? "memberList" + : "chatView" + : state.ui.mobileViewActiveColumn, }, }; }); @@ -1170,6 +1377,69 @@ const useStore = create((set, get) => ({ })); }, + // 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, + }; + }); + }, + // Metadata actions metadataGet: (serverId, target, keys) => { if (serverSupportsMetadata(serverId)) { @@ -1200,7 +1470,15 @@ const useStore = create((set, get) => ({ metadataSub: (serverId, keys) => { if (serverSupportsMetadata(serverId)) { + console.log( + `[METADATA_SUB] Subscribing to keys for server ${serverId}:`, + keys, + ); ircClient.metadataSub(serverId, keys); + } else { + console.log( + `[METADATA_SUB] Server ${serverId} does not support metadata`, + ); } }, @@ -1275,6 +1553,23 @@ registerAllProtocolHandlers(ircClient, useStore); ircClient.on("CHANMSG", (response) => { const { mtags, channelName, message, timestamp } = response; + // 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 + console.log( + `[IGNORE] Skipping message from ignored user: ${response.sender}`, + ); + return; + } + // Find the server and channel const server = useStore .getState() @@ -1290,7 +1585,7 @@ ircClient.on("CHANMSG", (response) => { : null; const replyMessage = replyId - ? findChannelMessageById(server.id, channel.id, replyId) + ? findChannelMessageById(server.id, channel.id, replyId) || null : null; const newMessage = { @@ -1318,6 +1613,7 @@ ircClient.on("CHANMSG", (response) => { if (user.username === response.sender) { return { ...user, + isBot: true, // Set bot flag from message tags metadata: { ...user.metadata, bot: { value: "true", visibility: "public" }, @@ -1337,6 +1633,20 @@ ircClient.on("CHANMSG", (response) => { } useStore.getState().addMessage(newMessage); + + // Play notification sound if appropriate + 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}`; @@ -1352,16 +1662,194 @@ ircClient.on("CHANMSG", (response) => { } }); +// Handle multiline messages +ircClient.on("MULTILINE_MESSAGE", (response) => { + console.log("[STORE] Received MULTILINE_MESSAGE:", response); + const { mtags, channelName, sender, message, messageIds, timestamp } = + response; + + // Check if sender is ignored + const globalSettings = useStore.getState().globalSettings; + if (isUserIgnored(sender, undefined, undefined, globalSettings.ignoreList)) { + // User is ignored, skip processing this message + console.log( + `[IGNORE] Skipping multiline message from ignored user: ${sender}`, + ); + 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 === channelName) + : 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, + }; + + console.log("[STORE] Created multiline message with IDs:", messageIds); + + // 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 }; + }); + } + + useStore.getState().addMessage(newMessage); + + // Play notification sound if appropriate + 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, + }; + 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 + 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, message, timestamp } = response; // Don't create private chats with ourselves when the server echoes back our own messages - const currentUser = useStore.getState().currentUser; + 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 + console.log( + `[IGNORE] Skipping private message from ignored user: ${sender}`, + ); + return; + } + // Find the server const server = useStore .getState() @@ -1407,6 +1895,7 @@ ircClient.on("USERMSG", (response) => { if (user.username === sender) { return { ...user, + isBot: true, // Set bot flag from message tags metadata: { ...user.metadata, bot: { value: "true", visibility: "public" }, @@ -1427,6 +1916,19 @@ ircClient.on("USERMSG", (response) => { useStore.getState().addMessage(newMessage); + // Play notification sound if appropriate + 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}-${privateChat.id}`; @@ -1467,64 +1969,194 @@ ircClient.on("USERMSG", (response) => { } }); -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 === channelName) { - return { ...channel, users }; - } - return channel; - }); +ircClient.on("CHANNNOTICE", (response) => { + const { mtags, channelName, message, timestamp } = response; - return { ...server, channels: updatedChannels }; - } - return server; - }); + // 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 + console.log( + `[IGNORE] Skipping channel notice from ignored user: ${response.sender}`, + ); + return; + } - return { servers: updatedServers }; - }); + // Find the server and channel + const server = useStore + .getState() + .servers.find((s) => s.id === response.serverId); + + if (!server) return; + + const channel = server.channels.find((c) => c.name === channelName); + + if (channel) { + const newMessage: Message = { + id: uuidv4(), + type: "notice", // Different message type for notices + content: message, + timestamp: timestamp, + userId: response.sender, + channelId: channel.id, + serverId: server.id, + reactions: [], + replyMessage: null, + mentioned: [], + tags: mtags, + }; + + useStore.getState().addMessage(newMessage); - // Request metadata for all users in the channel (except current user) - const currentState = useStore.getState(); - const currentUser = currentState.currentUser; - users.forEach((user, index) => { - if (currentUser && user.username !== currentUser.username) { - // Stagger requests to avoid overwhelming the server - setTimeout(() => { - useStore.getState().metadataList(serverId, user.username); - }, index * 200); // 200ms delay between requests + // Play notification sound if appropriate + const state = useStore.getState(); + const serverCurrentUser = ircClient.getCurrentUser(response.serverId); + if ( + shouldPlayNotificationSound( + newMessage, + serverCurrentUser, + state.globalSettings, + ) + ) { + playNotificationSound(state.globalSettings); } - }); - const usersToFetch = users.filter( - (u) => u.username !== currentUser?.username, - ); + } +}); - // Process in batches with shorter delays - const batchSize = 10; - const batchDelay = 500; // 500ms between batches - - for (let i = 0; i < usersToFetch.length; i += batchSize) { - const batch = usersToFetch.slice(i, i + batchSize); - setTimeout( - () => { - batch.forEach((user, idx) => { - setTimeout(() => { - useStore.getState().metadataList(serverId, user.username); - }, idx * 50); // 50ms between requests in a batch - }); - }, - Math.floor(i / batchSize) * batchDelay, +ircClient.on("USERNOTICE", (response) => { + const { mtags, message, timestamp } = response; + + // 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 + console.log( + `[IGNORE] Skipping user notice from ignored user: ${response.sender}`, ); + return; } -}); -ircClient.on("JOIN", ({ serverId, username, channelName }) => { - useStore.setState((state) => { - const updatedServers = state.servers.map((server) => { - if (server.id === serverId) { - const existingChannel = server.channels.find( + // Find the server + const server = useStore + .getState() + .servers.find((s) => s.id === response.serverId); + + if (server) { + // Create private chat for the sender if it doesn't exist + let privateChat = server.privateChats.find( + (pc) => pc.username === response.sender, + ); + + if (!privateChat) { + const newPrivateChat: PrivateChat = { + id: `${server.id}-${response.sender}`, + username: response.sender, + serverId: server.id, + unreadCount: 0, + isMentioned: false, + lastActivity: new Date(), + }; + + useStore.setState((state) => { + const updatedServers = state.servers.map((s) => { + if (s.id === server.id) { + return { ...s, privateChats: [...s.privateChats, newPrivateChat] }; + } + return s; + }); + return { servers: updatedServers }; + }); + + privateChat = newPrivateChat; + } + + if (privateChat) { + const newMessage: Message = { + id: uuidv4(), + type: "notice", // Different message type for notices + content: message, + timestamp: timestamp, + userId: response.sender, + channelId: privateChat.id, + serverId: server.id, + reactions: [], + replyMessage: null, + mentioned: [], + tags: mtags, + }; + + useStore.getState().addMessage(newMessage); + + // Play notification sound if appropriate + const state = useStore.getState(); + const serverCurrentUser = ircClient.getCurrentUser(response.serverId); + if ( + shouldPlayNotificationSound( + newMessage, + serverCurrentUser, + state.globalSettings, + ) + ) { + playNotificationSound(state.globalSettings); + } + + // Update private chat's last activity and unread count + useStore.setState((state) => { + const updatedServers = state.servers.map((s) => { + if (s.id === server.id) { + const updatedPrivateChats = + s.privateChats?.map((pc) => { + if (pc.id === privateChat?.id) { + return { + ...pc, + lastActivity: new Date(), + unreadCount: pc.unreadCount + 1, + }; + } + return pc; + }) || []; + return { ...s, privateChats: updatedPrivateChats }; + } + return s; + }); + return { servers: updatedServers }; + }); + } + } +}); + +ircClient.on("JOIN", ({ serverId, username, channelName, 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: "JOIN", + data: { serverId, username, channelName }, + }); + return; + } + } + + useStore.setState((state) => { + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + const existingChannel = server.channels.find( (channel) => channel.name === channelName, ); @@ -1553,11 +2185,10 @@ ircClient.on("JOIN", ({ serverId, username, channelName }) => { ); if (!userAlreadyExists) { // Check if this is the current user and copy their metadata - const isCurrentUser = state.currentUser?.username === username; + const ircCurrentUser = ircClient.getCurrentUser(serverId); + const isCurrentUser = ircCurrentUser?.username === username; const userMetadata = - isCurrentUser && state.currentUser - ? state.currentUser.metadata - : {}; + isCurrentUser && ircCurrentUser ? ircCurrentUser.metadata : {}; return { ...channel, @@ -1583,14 +2214,15 @@ ircClient.on("JOIN", ({ serverId, username, channelName }) => { return server; }); - // Request metadata for the joining user - const currentUser = state.currentUser; - if (currentUser) { - // Small delay to avoid spamming the server - setTimeout(() => { - useStore.getState().metadataList(serverId, username); - }, 100); - } + // Request metadata for the joining user is not needed since we have subscriptions + // The metadata subscription (SUB) will automatically send us updates when metadata changes + // Commenting out to reduce server load and batch spam + // const currentUser = state.currentUser; + // if (currentUser) { + // setTimeout(() => { + // useStore.getState().metadataList(serverId, username); + // }, 100); + // } return { servers: updatedServers }; }); @@ -1598,8 +2230,40 @@ ircClient.on("JOIN", ({ serverId, username, channelName }) => { // If we joined a channel, request channel information const ourNick = ircClient.getNick(serverId); if (username === ourNick) { - ircClient.sendRaw(serverId, `NAMES ${channelName}`); + // Request topic and user list ircClient.sendRaw(serverId, `TOPIC ${channelName}`); + ircClient.sendRaw(serverId, `WHO ${channelName}`); + } + + // 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 === channelName); + 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], + }, + })); + } + } } }); @@ -1645,9 +2309,21 @@ ircClient.on("NICK", ({ serverId, oldNick, newNick }) => { return server; }); - // Update currentUser if it was our nick that changed + // 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; - if (state.currentUser && state.currentUser.username === oldNick) { + 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 }; } @@ -1656,9 +2332,115 @@ ircClient.on("NICK", ({ serverId, oldNick, newNick }) => { 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 }) => { +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; + } + } + useStore.setState((state) => { const updatedServers = state.servers.map((server) => { if (server.id === serverId) { @@ -1676,14 +2458,114 @@ ircClient.on("QUIT", ({ serverId, username, reason }) => { return { servers: updatedServers }; }); + + // Add quit message if settings allow + const state = useStore.getState(); + if (state.globalSettings.showEvents && state.globalSettings.showQuits) { + const server = state.servers.find((s) => s.id === serverId); + if (server) { + // Add quit message to all channels where the user was present + server.channels.forEach((channel) => { + const userWasInChannel = channel.users.some( + (user) => user.username === username, + ); + if (userWasInChannel) { + 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], + }, + })); + } + }); + } + } }); -ircClient.on("ready", ({ serverId, serverName, nickname }) => { +ircClient.on("ready", async ({ serverId, serverName, nickname }) => { console.log(`Server ready: serverId=${serverId}, serverName=${serverName}`); // Restore metadata for this server restoreServerMetadata(serverId); + // Send saved metadata to the server (after 001 ready) + // Only if server supports metadata + if (serverSupportsMetadata(serverId)) { + console.log( + `[READY] Server ${serverId} supports metadata, setting up subscriptions and checking existing data`, + ); + + // 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", + ]; + console.log( + `[READY] Subscribing to metadata keys for server ${serverId}:`, + defaultKeys, + ); + useStore.getState().metadataSub(serverId, defaultKeys); + } else { + console.log( + `[READY] Already subscribed to metadata keys for server ${serverId}:`, + currentSubs, + ); + } + + // Fetch our own metadata from the server first + // This will update saved values with what the server has + console.log(`[READY] Fetching own metadata from server ${serverId}`); + 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) { + console.log(`[READY] Sending updated metadata for server ${serverId}`); + const ourMetadata = serverMetadata[ourNick]; + if (ourMetadata) { + // Send our own metadata to the server + Object.entries(ourMetadata).forEach(([key, { value, visibility }]) => { + if (value !== undefined) { + console.log( + `[READY] Sending metadata: target=*, key=${key}, value=${value}`, + ); + useStore + .getState() + .metadataSet(serverId, "*", key, value, visibility); + } + }); + } + } + } else { + console.log(`[READY] Server ${serverId} does not support metadata`); + } + useStore.setState((state) => { const updatedServers = state.servers.map((server) => { if (server.id === serverId) { @@ -1692,11 +2574,31 @@ ircClient.on("ready", ({ serverId, serverName, nickname }) => { return server; }); - const ircCurrentUser = ircClient.getCurrentUser(); - const updatedCurrentUser = - state.currentUser && ircCurrentUser - ? { ...ircCurrentUser, metadata: state.currentUser.metadata } - : ircCurrentUser || state.currentUser; + 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, + }, + }; + + console.log( + `[READY] Set current user for server ${serverId}:`, + updatedCurrentUser.username, + "with metadata:", + updatedCurrentUser.metadata, + ); + } return { servers: updatedServers, @@ -1728,24 +2630,60 @@ ircClient.on("ready", ({ serverId, serverName, nickname }) => { } }); -ircClient.on("PART", ({ username, channelName }) => { +ircClient.on("PART", ({ serverId, username, channelName, reason }) => { console.log(`User ${username} left channel ${channelName}`); useStore.setState((state) => { const updatedServers = state.servers.map((server) => { - const updatedChannels = server.channels.map((channel) => { - if (channel.name === channelName) { - return { - ...channel, - users: channel.users.filter((user) => user.username !== username), // Remove the user - }; - } - return channel; - }); - return { ...server, channels: updatedChannels }; + if (server.id === serverId) { + const updatedChannels = server.channels.map((channel) => { + if (channel.name === channelName) { + 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], + }, + })); + } + } + } }); ircClient.on("KICK", ({ username, target, channelName, reason }) => { @@ -1942,9 +2880,9 @@ ircClient.on("CHANMSG", (response) => { ircClient.on("TAGMSG", (response) => { const { sender, mtags, channelName } = response; - // Check if the sender is not the current user + // 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 = useStore.getState().currentUser; + const currentUser = ircClient.getCurrentUser(response.serverId); if (sender !== currentUser?.username && mtags && mtags["+typing"]) { const isActive = mtags["+typing"] === "active"; const server = useStore @@ -2180,6 +3118,97 @@ ircClient.on("REDACT", ({ serverId, target, msgid, sender }) => { }); }); +// Nick error event handler +ircClient.on("NICK_ERROR", ({ serverId, code, error, nick, message }) => { + console.log(`[NICK_ERROR] ${code} ${error}: ${message}`); + + // Handle 433 (nickname already in use) with automatic retry + if (code === "433" && nick) { + const newNick = `${nick}_`; + console.log( + `Nickname '${nick}' already in use, retrying with '${newNick}'`, + ); + + // 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 && state.ui.selectedChannelId) { + const channel = server.channels.find( + (c) => c.id === state.ui.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 && state.ui.selectedChannelId) { + const channel = server.channels.find( + (c) => c.id === state.ui.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 }) => { console.log(`[FAIL] ${command} ${code} ${target || ""}: ${message}`); @@ -2384,26 +3413,46 @@ ircClient.on("METADATA", ({ serverId, target, key, visibility, value }) => { `[METADATA] Received metadata: server=${serverId}, target=${target}, key=${key}, value=${value}, visibility=${visibility}`, ); 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; + + console.log( + `[METADATA] Resolving target "${target}" to "${resolvedTarget}"`, + ); + console.log( + `[METADATA] Looking for user in ${state.servers.find((s) => s.id === serverId)?.channels.length || 0} channels`, + ); + 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 === target) { - const metadata = user.metadata || {}; + 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 (target === channel.name || target.startsWith("#")) { + const channelMetadata = { ...(channel.metadata || {}) }; + if ( + resolvedTarget === channel.name || + resolvedTarget.startsWith("#") + ) { if (value) { channelMetadata[key] = { value, visibility }; } else { @@ -2415,8 +3464,8 @@ ircClient.on("METADATA", ({ serverId, target, key, visibility, value }) => { }); // Update metadata for the server itself if target is server - const updatedMetadata = server.metadata || {}; - if (target === server.name) { + const updatedMetadata = { ...(server.metadata || {}) }; + if (resolvedTarget === server.name) { if (value) { updatedMetadata[key] = { value, visibility }; } else { @@ -2433,16 +3482,45 @@ ircClient.on("METADATA", ({ serverId, target, key, visibility, value }) => { return server; }); - // Update current user metadata + // Update current user metadata if the target matches any connected user let updatedCurrentUser = state.currentUser; - if (state.currentUser?.username === target) { - const metadata = state.currentUser.metadata || {}; - if (value) { - metadata[key] = { value, visibility }; - } else { - delete metadata[key]; + 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]; + } + updatedCurrentUser = { ...currentUserForServer, metadata }; + console.log( + `[METADATA] Updated current user ${resolvedTarget} on server ${serverId} with ${key}=${value}`, + ); + } + // 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]; + } + updatedCurrentUser = { ...state.currentUser, metadata }; + console.log( + `[METADATA] Updated global current user ${resolvedTarget} with ${key}=${value}`, + ); } - updatedCurrentUser = { ...state.currentUser, metadata }; } // Save metadata to localStorage @@ -2450,13 +3528,13 @@ ircClient.on("METADATA", ({ serverId, target, key, visibility, value }) => { if (!savedMetadata[serverId]) { savedMetadata[serverId] = {}; } - if (!savedMetadata[serverId][target]) { - savedMetadata[serverId][target] = {}; + if (!savedMetadata[serverId][resolvedTarget]) { + savedMetadata[serverId][resolvedTarget] = {}; } if (value) { - savedMetadata[serverId][target][key] = { value, visibility }; + savedMetadata[serverId][resolvedTarget][key] = { value, visibility }; } else { - delete savedMetadata[serverId][target][key]; + delete savedMetadata[serverId][resolvedTarget][key]; } saveMetadataToLocalStorage(savedMetadata); @@ -2470,16 +3548,63 @@ ircClient.on( console.log( `[METADATA_KEYVALUE] Received: server=${serverId}, target=${target}, key=${key}, value=${value}, visibility=${visibility}`, ); + + 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; + + console.log( + `[METADATA_KEYVALUE] Resolving target "${target}" to "${resolvedTarget}"`, + ); + + // If we're fetching our own metadata, update saved values + if (isFetchingOwn && target === "*") { + console.log( + `[METADATA_KEYVALUE] Updating saved metadata during fetch: ${key}=${value}`, + ); + const savedMetadata = loadSavedMetadata(); + if (!savedMetadata[serverId]) { + savedMetadata[serverId] = {}; + } + if (!savedMetadata[serverId][resolvedTarget]) { + savedMetadata[serverId][resolvedTarget] = {}; + } + // Overwrite saved value with server value + savedMetadata[serverId][resolvedTarget][key] = { value, visibility }; + saveMetadataToLocalStorage(savedMetadata); + } + + console.log( + `[METADATA_KEYVALUE] Looking for user in ${state.servers.find((s) => s.id === serverId)?.channels.length || 0} channels`, + ); + 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) { + console.log( + `[METADATA_KEYVALUE] Found user ${resolvedTarget} in channel ${channel.name}`, + ); + } + const updatedUsers = channel.users.map((user) => { - if (user.username === target) { - const metadata = user.metadata || {}; + if (user.username === resolvedTarget) { + const metadata = { ...(user.metadata || {}) }; metadata[key] = { value, visibility }; + console.log( + `[METADATA_KEYVALUE] Updated user ${resolvedTarget} in channel ${channel.name} with ${key}=${value}`, + ); return { ...user, metadata }; } return user; @@ -2487,7 +3612,10 @@ ircClient.on( // Update metadata for the channel itself if target matches channel name const channelMetadata = channel.metadata || {}; - if (target === channel.name || target.startsWith("#")) { + if ( + resolvedTarget === channel.name || + resolvedTarget.startsWith("#") + ) { channelMetadata[key] = { value, visibility }; } @@ -2497,28 +3625,38 @@ ircClient.on( metadata: channelMetadata, }; }); + + return { + ...server, + channels: updatedChannels, + }; } return server; }); // Update current user metadata let updatedCurrentUser = state.currentUser; - if (state.currentUser?.username === target) { - const metadata = state.currentUser.metadata || {}; + if (state.currentUser?.username === resolvedTarget) { + const metadata = { ...(state.currentUser.metadata || {}) }; metadata[key] = { value, visibility }; updatedCurrentUser = { ...state.currentUser, metadata }; + console.log( + `[METADATA_KEYVALUE] Updated current user ${resolvedTarget} with ${key}=${value}`, + ); } - // Save metadata to localStorage - const savedMetadata = loadSavedMetadata(); - if (!savedMetadata[serverId]) { - savedMetadata[serverId] = {}; - } - if (!savedMetadata[serverId][target]) { - savedMetadata[serverId][target] = {}; + // 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); } - savedMetadata[serverId][target][key] = { value, visibility }; - saveMetadataToLocalStorage(savedMetadata); return { servers: updatedServers, currentUser: updatedCurrentUser }; }); @@ -2527,8 +3665,30 @@ ircClient.on( ircClient.on("METADATA_KEYNOTSET", ({ serverId, target, key }) => { console.log( - `[METADATA] Key not set: server=${serverId}, target=${target}, key=${key}`, + `[METADATA_KEYNOTSET] Key not set: server=${serverId}, target=${target}, key=${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; + + // If we're fetching our own metadata and the key is not set, delete it from saved values + if (isFetchingOwn && target === "*") { + console.log( + `[METADATA_KEYNOTSET] Removing key from saved metadata during fetch: ${key}`, + ); + 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) => { @@ -2536,7 +3696,7 @@ ircClient.on("METADATA_KEYNOTSET", ({ serverId, target, key }) => { // Remove metadata for users in channels const updatedChannels = server.channels.map((channel) => { const updatedUsers = channel.users.map((user) => { - if (user.username === target) { + if (user.username === resolvedTarget) { const metadata = user.metadata || {}; delete metadata[key]; return { ...user, metadata }; @@ -2546,12 +3706,16 @@ ircClient.on("METADATA_KEYNOTSET", ({ serverId, target, key }) => { // Remove metadata for the channel itself if target matches channel name const channelMetadata = channel.metadata || {}; - if (target === channel.name || target.startsWith("#")) { + if ( + resolvedTarget === channel.name || + resolvedTarget.startsWith("#") + ) { delete channelMetadata[key]; } return { ...channel, users: updatedUsers, metadata: channelMetadata }; }); + return { ...server, channels: updatedChannels }; } return server; }); @@ -2561,6 +3725,10 @@ ircClient.on("METADATA_KEYNOTSET", ({ serverId, target, key }) => { }); 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] || []; @@ -2640,10 +3808,17 @@ ircClient.on("CAP ACK", ({ serverId, cliCaps }) => { }); 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 = [ @@ -2654,26 +3829,17 @@ ircClient.on("CAP_ACKNOWLEDGED", ({ serverId, key, capabilities }) => { "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); } - // Restore saved metadata for this server - restoreServerMetadata(serverId); - const savedMetadata = loadSavedMetadata(); - const serverMetadata = savedMetadata[serverId]; - if (serverMetadata) { - // Send all saved metadata to the server - Object.entries(serverMetadata).forEach(([target, metadata]) => { - Object.entries(metadata).forEach(([key, { value, visibility }]) => { - if (value !== undefined) { - useStore - .getState() - .metadataSet(serverId, target, key, value, visibility); - } - }); - }); - } + // Note: Metadata restoration/sending is now handled in the "ready" event + // to ensure the server is ready to receive METADATA commands } }); @@ -2764,41 +3930,112 @@ ircClient.on("SETNAME", ({ serverId, user, realname }) => { }); }); -ircClient.on("WHO_REPLY", ({ serverId, nick, flags }) => { - const server = useStore.getState().servers.find((s) => s.id === serverId); - if (!server || !server.botMode) return; +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; + + // 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., "H@" means here and operator) + let channelStatus = ""; + 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; + } + + // 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, + avatar: undefined, + isOnline: true, + isAway: isAway, + isBot: false, + 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); - const botFlag = server.botMode; - const isBot = flags.includes(botFlag); + if (isBot) { + user.isBot = true; + user.metadata = { + bot: { value: "true", visibility: "public" }, + }; + } + } - if (isBot) { - // Update user objects in channels + // Update the channel's user list with this user 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 === nick) { - return { + 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, - bot: { value: "true", visibility: "public" }, }, }; + return { ...ch, users: updatedUsers }; } - return user; - }); - return { ...channel, users: updatedUsers }; + // Add new user + return { ...ch, users: [...ch.users, user] }; + } + return ch; }); + return { ...s, channels: updatedChannels }; } return s; }); + return { servers: updatedServers }; }); - } -}); + }, +); ircClient.on("WHOIS_BOT", ({ serverId, target }) => { // Update user objects in channels @@ -2810,9 +4047,14 @@ ircClient.on("WHOIS_BOT", ({ serverId, target }) => { if (user.username === target) { return { ...user, + isBot: true, // Set the WHOIS-detected bot flag metadata: { ...user.metadata, - bot: { value: "true", visibility: "public" }, + // Keep bot metadata if it exists, but don't require it for display + bot: user.metadata?.bot || { + value: "true", + visibility: "public", + }, }, }; } @@ -2828,4 +4070,364 @@ ircClient.on("WHOIS_BOT", ({ serverId, target }) => { }); }); +// AWAY event handler for away-notify extension +ircClient.on("AWAY", ({ serverId, username, awayMessage }) => { + console.log( + `[AWAY] User ${username} on server ${serverId} away status changed: ${awayMessage ? "away" : "here"}`, + ); + + 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 306 numeric - we are now marked as away +ircClient.on("RPL_NOWAWAY", ({ serverId, message }) => { + console.log( + `[RPL_NOWAWAY] We are now marked as away on server ${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 }) => { + console.log( + `[RPL_UNAWAY] We are no longer marked as away on server ${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 }) => { + console.log(`[BATCH] Starting batch: ${batchId} of type ${type}`); + 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 }) => { + console.log(`[BATCH] Ending batch: ${batchId}`); + useStore.setState((state) => { + const serverBatches = state.activeBatches[serverId]; + if (!serverBatches || !serverBatches[batchId]) { + console.warn(`Batch ${batchId} not found for server ${serverId}`); + return state; + } + + const batch = serverBatches[batchId]; + console.log( + `[BATCH] Processing ${batch.events.length} events for batch ${batchId} of type ${batch.type}`, + ); + + // 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 + console.log(`[BATCH] Multiline batch ${batchId} handled by IRC client`); + } 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 + console.log(`[BATCH] Metadata batch ${batchId} handled by IRC client`); + } else if (batch.type === "chathistory") { + // Chathistory batch completed - turn off loading state for the channel + console.log(`[BATCH] Chathistory batch ${batchId} completed`); + + // 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) { + console.log( + `[CHATHISTORY] History loading completed for ${channelName}`, + ); + // Trigger event to turn off loading state + ircClient.triggerEvent("CHATHISTORY_LOADING", { + serverId, + channelName, + isLoading: false, + }); + } + } else { + // For unknown batch types, process events individually + console.log( + `Unknown batch type ${batch.type}, processing 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, + }, + }; + }); +}); + +// 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"]; + + console.log( + `Processing netsplit: ${quitEvents.length} users quit due to split between ${server1} and ${server2}`, + ); + + // 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"]; + + console.log( + `Processing netjoin: ${joinEvents.length} users joined due to rejoin between ${server1} and ${server2}`, + ); + + // 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 }) => { + console.log( + `[CHATHISTORY] Setting loading state for ${channelName}: ${isLoading}`, + ); + useStore.setState((state) => { + const updatedServers = state.servers.map((server) => { + if (server.id === serverId) { + const updatedChannels = server.channels.map((channel) => { + if (channel.name === channelName) { + return { ...channel, isLoadingHistory: isLoading }; + } + return channel; + }); + return { ...server, channels: updatedChannels }; + } + return server; + }); + return { servers: updatedServers }; + }); +}); + export default useStore; diff --git a/src/types/index.ts b/src/types/index.ts index 24fcbab2..ff157c44 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -5,7 +5,10 @@ export interface User { displayName?: string; account?: string; isOnline: boolean; + isAway?: boolean; // Whether user is marked as away (from WHO flags or AWAY notify) + awayMessage?: string; // Away message if user is away status?: string; + isBot?: boolean; // Bot detection from WHO response metadata?: Record; } @@ -18,6 +21,8 @@ export interface Server { privateChats: PrivateChat[]; icon?: string; isConnected: boolean; + isAway?: boolean; // Whether we are marked as away on this server + awayMessage?: string; // Our away message on this server users: User[]; capabilities?: string[]; metadata?: Record; @@ -26,6 +31,7 @@ export interface Server { } export interface ServerConfig { id: string; + name?: string; host: string; port: number; nickname: string; @@ -47,6 +53,7 @@ export interface Channel { messages: Message[]; users: User[]; isRead?: boolean; + isLoadingHistory?: boolean; metadata?: Record; } @@ -65,23 +72,29 @@ export interface Reaction { } export interface Message { - id?: string; - msgid?: string; - content: string; - timestamp: Date; - userId: string; - channelId: string; - serverId: string; + id: string; + msgid?: string; // IRC message ID from IRCv3 message-ids capability + multilineMessageIds?: string[]; // For multiline messages: all message IDs that make up this message type: | "message" | "system" | "error" | "join" - | "leave" + | "part" + | "quit" | "nick" - | "standard-reply"; + | "leave" + | "standard-reply" + | "notice" + | "netsplit" + | "netjoin"; + content: string; + timestamp: Date; + userId: string; + channelId: string; + serverId: string; reactions: Reaction[]; - replyMessage: Message | null | undefined; + replyMessage: Message | null; mentioned: string[]; tags?: Record; // Standard reply fields @@ -90,6 +103,11 @@ export interface Message { standardReplyCode?: string; standardReplyTarget?: string; standardReplyMessage?: string; + // Batch-related fields for netsplit/netjoin + batchId?: string; + quitUsers?: string[]; + server1?: string; + server2?: string; } // Alias for backwards compatibility diff --git a/test_multiline.md b/test_multiline.md new file mode 100644 index 00000000..eb2c7903 --- /dev/null +++ b/test_multiline.md @@ -0,0 +1,58 @@ +# Multiline Implementation Test + +## Changes Made + +1. **Updated capability negotiation to use `draft/multiline`** instead of `multiline` +2. **Added proper `draft/multiline-concat` tag support** for concatenating long lines +3. **Implemented two distinct behaviors:** + - **Multi-line messages**: Lines joined with `\n` (normal multiline) + - **Long single-line messages**: Lines joined without separator using `draft/multiline-concat` tag + +## Key Behaviors + +### Case 1: Multi-line message (has newlines) +``` +Input: "Hello\nWorld\nHow are you?" +Output: + BATCH +abc123 draft/multiline #channel + @batch=abc123 PRIVMSG #channel :Hello + @batch=abc123 PRIVMSG #channel :World + @batch=abc123 PRIVMSG #channel :How are you? + BATCH -abc123 + +Result: "Hello\nWorld\nHow are you?" +``` + +### Case 2: Single very long line (over 400 chars) +``` +Input: "This is a very long message that exceeds the IRC line limit and needs to be split using multiline-concat to preserve it as a single logical line without line breaks when displayed" +Output: + BATCH +def456 draft/multiline #channel + @batch=def456 PRIVMSG #channel :This is a very long message that exceeds the IRC line limit and needs to be split + @batch=def456;draft/multiline-concat PRIVMSG #channel : using multiline-concat to preserve it as a single logical line without line breaks when displayed + BATCH -def456 + +Result: "This is a very long message that exceeds the IRC line limit and needs to be split using multiline-concat to preserve it as a single logical line without line breaks when displayed" +``` + +### Case 3: Multi-line with some long lines +``` +Input: "Short line\nThis is a very long line that needs to be split but should still be treated as a separate line from the short line above it\nAnother short line" +Output: + BATCH +ghi789 draft/multiline #channel + @batch=ghi789 PRIVMSG #channel :Short line + @batch=ghi789 PRIVMSG #channel :This is a very long line that needs to be split but should still be treated as a separate + @batch=ghi789;draft/multiline-concat PRIVMSG #channel : line from the short line above it + @batch=ghi789 PRIVMSG #channel :Another short line + BATCH -ghi789 + +Result: "Short line\nThis is a very long line that needs to be split but should still be treated as a separate line from the short line above it\nAnother short line" +``` + +## Testing Instructions + +1. Connect to an IRC server that supports `draft/multiline` (like Ergo) +2. Test sending multi-line messages (type with Shift+Enter for new lines) +3. Test sending very long single-line messages (over 400 characters) +4. Verify that line breaks are preserved in multi-line cases +5. Verify that long single lines are displayed as continuous text without unwanted line breaks \ No newline at end of file diff --git a/tests/App.test.tsx b/tests/App.test.tsx index b2d0467a..a2761067 100644 --- a/tests/App.test.tsx +++ b/tests/App.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import App from "../src/App"; @@ -30,6 +30,60 @@ describe("App", () => { afterEach(() => { vi.clearAllMocks(); + // Reset store state to prevent test interference + useStore.setState({ + servers: [], + currentUser: null, + isConnecting: false, + selectedServerId: null, + connectionError: null, + messages: {}, + typingUsers: {}, + ui: { + selectedServerId: null, + selectedChannelId: null, + selectedPrivateChatId: null, + isAddServerModalOpen: false, + isSettingsModalOpen: false, + isUserProfileModalOpen: false, + isDarkMode: true, + isMobileMenuOpen: false, + isMemberListVisible: true, + isChannelListVisible: true, + isChannelListModalOpen: false, + isChannelRenameModalOpen: false, + mobileViewActiveColumn: "serverList", + isServerMenuOpen: false, + contextMenu: { + isOpen: false, + x: 0, + y: 0, + type: "server", + itemId: null, + }, + prefillServerDetails: null, + }, + globalNotifications: [], + globalSettings: { + enableNotifications: true, + notificationSound: "pop", + enableNotificationSounds: true, + enableHighlights: true, + sendTypingNotifications: true, + showEvents: true, + showNickChanges: true, + showJoinsParts: true, + showQuits: true, + customMentions: [], + ignoreList: ["HistServ!*@*"], + nickname: "", + accountName: "", + accountPassword: "", + enableMultilineInput: true, + multilineOnShiftEnter: true, + autoFallbackToSingleLine: true, + }, + }); }); describe("Server Management", () => { @@ -52,7 +106,17 @@ describe("App", () => { const user = userEvent.setup(); // Mock successful connection - vi.mocked(ircClient.connect).mockResolvedValueOnce(); + vi.mocked(ircClient.connect).mockResolvedValueOnce({ + id: "test-server", + name: "Test Server", + host: "irc.test.com", + port: 443, + channels: [], + privateChats: [], + isConnected: true, + users: [], + capabilities: [], + }); // Open modal and fill form await user.click(screen.getByTestId("server-list-options-button")); @@ -81,11 +145,12 @@ describe("App", () => { // Verify connection attempt expect(ircClient.connect).toHaveBeenCalledWith( + "Test Server", "irc.test.com", 443, "tester", "", - "", + "tester", "c3VwZXIgYXdlc29tZSBwYXNzd29yZCBsbWFvIDEyMyAhPyE/IQ==", undefined, ); @@ -104,6 +169,11 @@ describe("App", () => { await user.click(screen.getByTestId("server-list-options-button")); await user.click(screen.getByText(/Add Server/i)); + // Wait for modal to be open + await waitFor(() => { + expect(screen.getByPlaceholderText(/ExampleNET/i)).toBeInTheDocument(); + }); + await user.type( screen.getByPlaceholderText(/ExampleNET/i), "Test Server", @@ -117,8 +187,10 @@ describe("App", () => { // Submit form await user.click(screen.getByRole("button", { name: /^connect$/i })); - // Verify error message - expect(screen.getByText("Connection failed")).toBeInTheDocument(); + // Verify error message appears after async connection failure + await waitFor(() => { + expect(screen.getByText("Connection failed")).toBeInTheDocument(); + }); }); }); @@ -129,7 +201,7 @@ describe("App", () => { // Setup initial state with a user useStore.setState({ - currentUser: { id: "user1", username: "testuser" }, + currentUser: { id: "user1", username: "testuser", isOnline: true }, }); // Open settings diff --git a/tests/components/ChatArea.test.tsx b/tests/components/ChatArea.test.tsx index 03f8b1fa..627e0496 100644 --- a/tests/components/ChatArea.test.tsx +++ b/tests/components/ChatArea.test.tsx @@ -11,6 +11,7 @@ vi.mock("../../src/lib/ircClient", () => ({ sendRaw: vi.fn(), sendTyping: vi.fn(), on: vi.fn(), + getCurrentUser: vi.fn(() => ({ id: "test-user", username: "tester" })), version: "1.0.0", }, })); @@ -70,6 +71,8 @@ describe("ChatArea Tab Completion Integration", () => { isUserProfileModalOpen: false, isDarkMode: true, isMobileMenuOpen: false, + isChannelListModalOpen: false, + isChannelRenameModalOpen: false, mobileViewActiveColumn: "serverList", isServerMenuOpen: false, contextMenu: { diff --git a/tests/components/MetadataDisplay.test.tsx b/tests/components/MetadataDisplay.test.tsx index b19bb411..d3bb1543 100644 --- a/tests/components/MetadataDisplay.test.tsx +++ b/tests/components/MetadataDisplay.test.tsx @@ -11,6 +11,7 @@ vi.mock("../../src/lib/ircClient", () => ({ sendRaw: vi.fn(), sendTyping: vi.fn(), on: vi.fn(), + getCurrentUser: vi.fn(() => ({ id: "test-user", username: "tester" })), version: "1.0.0", }, })); @@ -69,19 +70,25 @@ const mockChannel: Channel = { id: "msg1", userId: "alice-server1", content: "Hello everyone!", - timestamp: new Date().toISOString(), - type: "message", + timestamp: new Date(), + type: "message" as const, serverId: "server1", channelId: "channel1", + reactions: [], + replyMessage: null, + mentioned: [], }, { id: "msg2", userId: "bob-server1", content: "Hi Alice!", - timestamp: new Date().toISOString(), - type: "message", + timestamp: new Date(), + type: "message" as const, serverId: "server1", channelId: "channel1", + reactions: [], + replyMessage: null, + mentioned: [], }, ], users: mockUsersWithMetadata, @@ -126,6 +133,8 @@ describe("Metadata Display Features", () => { isUserProfileModalOpen: false, isDarkMode: true, isMobileMenuOpen: false, + isChannelListModalOpen: false, + isChannelRenameModalOpen: false, mobileViewActiveColumn: "serverList", isServerMenuOpen: false, contextMenu: { @@ -313,10 +322,13 @@ describe("Metadata Display Features", () => { id: "msg3", userId: "alice-server1", content: "\u0001ACTION waves hello\u0001", - timestamp: new Date().toISOString(), - type: "message", + timestamp: new Date(), + type: "message" as const, serverId: "server1", channelId: "channel1", + reactions: [], + replyMessage: null, + mentioned: [], }; useStore.setState((state) => ({ diff --git a/tests/components/UserSettings.test.tsx b/tests/components/UserSettings.test.tsx new file mode 100644 index 00000000..6253509d --- /dev/null +++ b/tests/components/UserSettings.test.tsx @@ -0,0 +1,140 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import UserSettings from "../../src/components/ui/UserSettings"; + +// Extend window interface for test environment +declare global { + interface Window { + __HIDE_SERVER_LIST__?: boolean; + } +} + +// 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: "" }, + }, + }, + ], + }, + ], + }, + ], + ui: { + selectedServerId: "server1", + isSettingsModalOpen: true, + }, + globalSettings: { + enableNotificationSounds: true, + notificationSound: "", + 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(), + })), + serverSupportsMetadata: vi.fn(() => true), +})); + +// Mock ircClient +vi.mock("../../src/lib/ircClient", () => ({ + default: { + getCurrentUser: vi.fn(() => ({ id: "user1", username: "testuser" })), + connect: vi.fn(), + sendRaw: vi.fn(), + on: vi.fn(), + version: "1.0.0", + }, +})); + +// Mock Audio API +global.Audio = vi.fn().mockImplementation(() => ({ + play: vi.fn(), + pause: vi.fn(), + load: vi.fn(), +})) as unknown as { + new (src?: string): HTMLAudioElement; + prototype: HTMLAudioElement; +}; + +global.URL.createObjectURL = vi.fn(() => "blob:test-url"); + +describe("UserSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders the settings modal", () => { + render(); + expect(screen.getByText("User Settings")).toBeInTheDocument(); + }); + + it("displays notification settings with correct text", async () => { + render(); + + // Click on the Notifications tab first + fireEvent.click(screen.getByText("Notifications")); + + // Wait for the content to update and just check that the header changes + await waitFor(() => { + expect( + screen.getByRole("heading", { name: "Notifications" }), + ).toBeInTheDocument(); + }); + + // If the content area shows the notifications heading, the tab navigation works + // This test verifies the tab switching functionality + }); + + it("displays account password field with correct text", async () => { + // 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(); + + // Click on the Account tab first + fireEvent.click(screen.getByText("Account")); + + // Wait for the content to update + await waitFor(() => { + expect( + screen.getByRole("heading", { name: "Account" }), + ).toBeInTheDocument(); + }); + + // This test verifies the Account tab navigation works + }); +}); diff --git a/tests/lib/defaultIgnore.test.ts b/tests/lib/defaultIgnore.test.ts new file mode 100644 index 00000000..f52b7db2 --- /dev/null +++ b/tests/lib/defaultIgnore.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { isUserIgnored } from "../../src/lib/ignoreUtils"; + +describe("Default HistServ Ignore", () => { + it("should ignore HistServ by default", () => { + const defaultIgnoreList = ["HistServ!*@*"]; + + // Test that HistServ messages would be ignored + const result = isUserIgnored( + "HistServ", + "histserv", + "services.example.org", + defaultIgnoreList, + ); + expect(result).toBe(true); + }); + + it("should not ignore regular users with default ignore list", () => { + const defaultIgnoreList = ["HistServ!*@*"]; + + // Test that regular users are not ignored + const result = isUserIgnored( + "alice", + "alice_user", + "user.example.com", + defaultIgnoreList, + ); + expect(result).toBe(false); + }); +}); diff --git a/tests/lib/ignoreUtils.test.ts b/tests/lib/ignoreUtils.test.ts new file mode 100644 index 00000000..b427a230 --- /dev/null +++ b/tests/lib/ignoreUtils.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; +import { + createIgnorePattern, + isUserIgnored, + isValidIgnorePattern, + matchesIgnorePattern, +} from "../../src/lib/ignoreUtils"; + +describe("ignoreUtils", () => { + describe("matchesIgnorePattern", () => { + it("should match exact patterns", () => { + expect( + matchesIgnorePattern("nick!user@host.com", "nick!user@host.com"), + ).toBe(true); + expect( + matchesIgnorePattern("nick!user@host.com", "different!user@host.com"), + ).toBe(false); + }); + + it("should match wildcard patterns", () => { + expect(matchesIgnorePattern("baduser!user@host.com", "baduser!*@*")).toBe( + true, + ); + expect( + matchesIgnorePattern("anynick!baduser@host.com", "*!baduser@*"), + ).toBe(true); + expect( + matchesIgnorePattern("nick!user@badhost.com", "*!*@badhost.com"), + ).toBe(true); + expect( + matchesIgnorePattern("nick!user@sub.badhost.com", "*!*@*.badhost.com"), + ).toBe(true); + }); + + it("should be case insensitive", () => { + expect( + matchesIgnorePattern("NICK!USER@HOST.COM", "nick!user@host.com"), + ).toBe(true); + expect( + matchesIgnorePattern("nick!user@host.com", "NICK!USER@HOST.COM"), + ).toBe(true); + }); + + it("should handle invalid patterns gracefully", () => { + expect( + matchesIgnorePattern("nick!user@host.com", "invalid[pattern"), + ).toBe(false); + }); + }); + + describe("isUserIgnored", () => { + const ignoreList = [ + "baduser!*@*", + "*!spammer@*", + "*!*@badhost.com", + "exact!match@host.net", + ]; + + it("should ignore users by nick", () => { + expect( + isUserIgnored("baduser", "anyuser", "anyhost.com", ignoreList), + ).toBe(true); + expect( + isUserIgnored("gooduser", "anyuser", "anyhost.com", ignoreList), + ).toBe(false); + }); + + it("should ignore users by username", () => { + expect( + isUserIgnored("anynick", "spammer", "anyhost.com", ignoreList), + ).toBe(true); + expect( + isUserIgnored("anynick", "gooduser", "anyhost.com", ignoreList), + ).toBe(false); + }); + + it("should ignore users by host", () => { + expect( + isUserIgnored("anynick", "anyuser", "badhost.com", ignoreList), + ).toBe(true); + expect( + isUserIgnored("anynick", "anyuser", "goodhost.com", ignoreList), + ).toBe(false); + }); + + it("should handle partial information", () => { + expect(isUserIgnored("baduser", undefined, undefined, ignoreList)).toBe( + true, + ); + expect(isUserIgnored("anynick", "spammer", undefined, ignoreList)).toBe( + true, + ); + expect( + isUserIgnored("anynick", undefined, "badhost.com", ignoreList), + ).toBe(true); + }); + + it("should handle empty ignore list", () => { + expect(isUserIgnored("baduser", "spammer", "badhost.com", [])).toBe( + false, + ); + }); + }); + + describe("isValidIgnorePattern", () => { + it("should validate correct patterns", () => { + expect(isValidIgnorePattern("nick!user@host")).toBe(true); + expect(isValidIgnorePattern("*!*@*")).toBe(true); + expect(isValidIgnorePattern("baduser!*@*")).toBe(true); + expect(isValidIgnorePattern("*!spammer@*")).toBe(true); + expect(isValidIgnorePattern("*!*@badhost.com")).toBe(true); + expect(isValidIgnorePattern("nick123!user_name@sub.domain.com")).toBe( + true, + ); + }); + + it("should reject invalid patterns", () => { + expect(isValidIgnorePattern("")).toBe(false); + expect(isValidIgnorePattern(" ")).toBe(false); + expect(isValidIgnorePattern("nick@host")).toBe(false); // missing ! + expect(isValidIgnorePattern("nick!user")).toBe(false); // missing @ + expect(isValidIgnorePattern("nick!user@")).toBe(false); // empty host + expect(isValidIgnorePattern("!user@host")).toBe(false); // empty nick + expect(isValidIgnorePattern("nick!@host")).toBe(false); // empty user + expect(isValidIgnorePattern("nick!user@host!extra")).toBe(false); // too many ! + expect(isValidIgnorePattern("nick!user@host@extra")).toBe(false); // too many @ + }); + }); + + describe("createIgnorePattern", () => { + it("should create patterns with all components", () => { + expect(createIgnorePattern("nick", "user", "host")).toBe( + "nick!user@host", + ); + }); + + it("should use wildcards for missing components", () => { + expect(createIgnorePattern("nick")).toBe("nick!*@*"); + expect(createIgnorePattern(undefined, "user")).toBe("*!user@*"); + expect(createIgnorePattern(undefined, undefined, "host")).toBe( + "*!*@host", + ); + expect(createIgnorePattern("nick", "user")).toBe("nick!user@*"); + }); + + it("should handle empty strings", () => { + expect(createIgnorePattern("", "", "")).toBe("*!*@*"); + expect(createIgnorePattern("nick", "", "")).toBe("nick!*@*"); + }); + }); +}); diff --git a/tests/lib/ircClient.test.ts b/tests/lib/ircClient.test.ts index 3caa920a..78ba0c59 100644 --- a/tests/lib/ircClient.test.ts +++ b/tests/lib/ircClient.test.ts @@ -83,6 +83,7 @@ describe("IRCClient", () => { MockWebSocketSpy.mockReturnValue(mockSocket); const connectionPromise = client.connect( + "Test Server", "irc.example.com", 443, "testuser", @@ -94,7 +95,7 @@ describe("IRCClient", () => { const server = await connectionPromise; expect(server).toBeDefined(); - expect(server.name).toBe("irc.example.com"); + expect(server.name).toBe("Test Server"); expect(server.isConnected).toBe(true); // Verify sent messages @@ -108,6 +109,7 @@ describe("IRCClient", () => { MockWebSocketSpy.mockReturnValue(mockSocket); const connectionPromise = client.connect( + "Test Server", "irc.example.com", 443, "testuser", @@ -130,6 +132,7 @@ describe("IRCClient", () => { MockWebSocketSpy.mockReturnValue(mockSocket1); const firstConnectionPromise = client.connect( + "Test Server", "irc.example.com", 443, "testuser", @@ -139,13 +142,14 @@ describe("IRCClient", () => { const firstServer = await firstConnectionPromise; expect(firstServer).toBeDefined(); - expect(firstServer.name).toBe("irc.example.com"); + expect(firstServer.name).toBe("Test Server"); expect(firstServer.isConnected).toBe(true); expect(mockSocket1.sentMessages).toContain("CAP LS 302"); expect(MockWebSocketSpy).toHaveBeenCalledTimes(1); // Second connection to same host/port should return existing server const secondConnectionPromise = client.connect( + "Test Server 2", "irc.example.com", 443, "testuser2", // Different nickname @@ -168,6 +172,7 @@ describe("IRCClient", () => { MockWebSocketSpy.mockReturnValue(mockSocket); const connectionPromise = client.connect( + "Test Server", "irc.example.com", 443, "testuser", @@ -212,6 +217,7 @@ describe("IRCClient", () => { MockWebSocketSpy.mockReturnValue(mockSocket); const connectionPromise = client.connect( + "Test Server", "irc.example.com", 443, "testuser", diff --git a/tests/lib/nicknameRetry.test.ts b/tests/lib/nicknameRetry.test.ts new file mode 100644 index 00000000..d83a6f9e --- /dev/null +++ b/tests/lib/nicknameRetry.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it, vi } from "vitest"; +import ircClient from "../../src/lib/ircClient"; +import useStore, { type AppState } from "../../src/store"; + +describe("Nickname retry functionality", () => { + it("should retry with underscore when receiving 433 error", () => { + // Mock the changeNick method + const changeNickSpy = vi.spyOn(ircClient, "changeNick"); + + // Mock the store state with minimal required properties + const mockState: Partial = { + servers: [ + { + id: "test-server", + name: "Test Server", + host: "test.com", + port: 6667, + isConnected: true, + channels: [ + { + id: "test-channel", + name: "#test", + isPrivate: false, + serverId: "test-server", + unreadCount: 0, + isMentioned: false, + messages: [], + users: [], + }, + ], + privateChats: [], + users: [], + }, + ], + ui: { + selectedServerId: "test-server", + selectedChannelId: "test-channel", + selectedPrivateChatId: null, + isAddServerModalOpen: false, + isSettingsModalOpen: false, + isUserProfileModalOpen: false, + isDarkMode: false, + isMobileMenuOpen: false, + isMemberListVisible: true, + isChannelListVisible: true, + isChannelListModalOpen: false, + isChannelRenameModalOpen: false, + mobileViewActiveColumn: "chatView", + isServerMenuOpen: false, + contextMenu: { + isOpen: false, + x: 0, + y: 0, + type: "server", + itemId: null, + }, + prefillServerDetails: null, + }, + addGlobalNotification: vi.fn(), + }; + + // Mock useStore.getState to return our mock state + vi.spyOn(useStore, "getState").mockReturnValue(mockState as AppState); + vi.spyOn(useStore, "setState").mockImplementation(() => {}); + + // Simulate a 433 error event + const nickErrorEvent = { + serverId: "test-server", + code: "433", + error: "Nickname already in use", + nick: "testuser", + message: "Nickname is already in use", + }; + + // Trigger the NICK_ERROR event + ircClient.triggerEvent("NICK_ERROR", nickErrorEvent); + + // Verify that changeNick was called with the original nick + underscore + expect(changeNickSpy).toHaveBeenCalledWith("test-server", "testuser_"); + + // Verify that addGlobalNotification was NOT called for 433 errors (since we auto-retry) + expect(mockState.addGlobalNotification).not.toHaveBeenCalled(); + + // Clean up + changeNickSpy.mockRestore(); + }); + + it("should not retry for other error codes", () => { + // Mock the changeNick method + const changeNickSpy = vi.spyOn(ircClient, "changeNick"); + + // Mock the store state with addGlobalNotification method + const mockState: Partial = { + servers: [ + { + id: "test-server", + name: "Test Server", + host: "test.com", + port: 6667, + isConnected: true, + channels: [ + { + id: "test-channel", + name: "#test", + isPrivate: false, + serverId: "test-server", + unreadCount: 0, + isMentioned: false, + messages: [], + users: [], + }, + ], + privateChats: [], + users: [], + }, + ], + ui: { + selectedServerId: "test-server", + selectedChannelId: "test-channel", + selectedPrivateChatId: null, + isAddServerModalOpen: false, + isSettingsModalOpen: false, + isUserProfileModalOpen: false, + isDarkMode: false, + isMobileMenuOpen: false, + isMemberListVisible: true, + isChannelListVisible: true, + isChannelListModalOpen: false, + isChannelRenameModalOpen: false, + mobileViewActiveColumn: "chatView", + isServerMenuOpen: false, + contextMenu: { + isOpen: false, + x: 0, + y: 0, + type: "server", + itemId: null, + }, + prefillServerDetails: null, + }, + addGlobalNotification: vi.fn(), + }; + + // Mock useStore methods + vi.spyOn(useStore, "getState").mockReturnValue(mockState as AppState); + vi.spyOn(useStore, "setState").mockImplementation(() => {}); + + // Simulate a 432 error event (invalid nickname) + const nickErrorEvent = { + serverId: "test-server", + code: "432", + error: "Invalid nickname", + nick: "testuser", + message: "Invalid nickname format", + }; + + // Trigger the NICK_ERROR event + ircClient.triggerEvent("NICK_ERROR", nickErrorEvent); + + // Verify that changeNick was NOT called for non-433 errors + expect(changeNickSpy).not.toHaveBeenCalled(); + + // Verify that addGlobalNotification WAS called for other error codes + expect(mockState.addGlobalNotification).toHaveBeenCalledWith({ + type: "fail", + command: "NICK", + code: "432", + message: "Invalid nickname: Invalid nickname format", + target: "testuser", + serverId: "test-server", + }); + + // Clean up + changeNickSpy.mockRestore(); + }); +}); diff --git a/tests/lib/notificationSounds.test.ts b/tests/lib/notificationSounds.test.ts new file mode 100644 index 00000000..9e36adcc --- /dev/null +++ b/tests/lib/notificationSounds.test.ts @@ -0,0 +1,266 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + playNotificationSound, + shouldPlayNotificationSound, +} from "../../src/lib/notificationSounds"; + +// Mock Web Audio API +const mockAudioContext = { + createOscillator: vi.fn(() => ({ + connect: vi.fn(), + frequency: { setValueAtTime: vi.fn() }, + type: "sine", + start: vi.fn(), + stop: vi.fn(), + })), + createGain: vi.fn(() => ({ + connect: vi.fn(), + gain: { + setValueAtTime: vi.fn(), + linearRampToValueAtTime: vi.fn(), + exponentialRampToValueAtTime: vi.fn(), + }, + })), + destination: {}, + currentTime: 0, +}; + +// Mock HTML Audio API +const mockAudio = { + play: vi.fn(() => Promise.resolve()), + volume: 0.5, +}; + +// Mock URL API +const mockURL = { + createObjectURL: vi.fn(() => "blob:test-url"), + revokeObjectURL: vi.fn(), +}; + +describe("notificationSounds", () => { + beforeEach(() => { + // Mock globals + global.window = global.window || {}; + ( + global.window as unknown as { + AudioContext: unknown; + webkitAudioContext: unknown; + } + ).AudioContext = vi.fn(() => mockAudioContext); + ( + global.window as unknown as { + AudioContext: unknown; + webkitAudioContext: unknown; + } + ).webkitAudioContext = vi.fn(() => mockAudioContext); + (global as unknown as { Audio: unknown }).Audio = vi.fn(() => mockAudio); + (global as unknown as { URL: unknown }).URL = mockURL; + + // Reset mocks + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("playNotificationSound", () => { + it("should not play sound if notification sounds are disabled", async () => { + const globalSettings = { + enableNotificationSounds: false, + notificationSound: "", + }; + + await playNotificationSound(globalSettings); + + expect(global.Audio).not.toHaveBeenCalled(); + expect(mockAudioContext.createOscillator).not.toHaveBeenCalled(); + }); + + it("should play default beep sound when no custom sound is set", async () => { + const globalSettings = { + enableNotificationSounds: true, + notificationSound: "", + }; + + await playNotificationSound(globalSettings); + + expect(mockAudioContext.createOscillator).toHaveBeenCalled(); + expect(mockAudioContext.createGain).toHaveBeenCalled(); + expect(global.Audio).not.toHaveBeenCalled(); + }); + + it("should play custom sound when notification sound is set", async () => { + const globalSettings = { + enableNotificationSounds: true, + notificationSound: "custom-sound-url", + }; + + await playNotificationSound(globalSettings); + + expect(global.Audio).toHaveBeenCalledWith("custom-sound-url"); + expect(mockAudio.play).toHaveBeenCalled(); + expect(mockAudio.volume).toBe(0.3); + }); + + it("should handle audio playback errors gracefully", async () => { + const globalSettings = { + enableNotificationSounds: true, + notificationSound: "invalid-url", + }; + + mockAudio.play.mockRejectedValueOnce(new Error("Audio failed")); + const consoleSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + await playNotificationSound(globalSettings); + + expect(consoleSpy).toHaveBeenCalledWith( + "Failed to play notification sound:", + expect.any(Error), + ); + consoleSpy.mockRestore(); + }); + }); + + describe("shouldPlayNotificationSound", () => { + const mockCurrentUser = { username: "testuser" }; + + it("should return false if notification sounds are disabled", () => { + const message = { + userId: "otheruser", + content: "Hello testuser!", + type: "message", + }; + const globalSettings = { + enableNotificationSounds: false, + enableHighlights: true, + customMentions: [], + }; + + const result = shouldPlayNotificationSound( + message, + mockCurrentUser, + globalSettings, + ); + expect(result).toBe(false); + }); + + it("should return false for messages from current user", () => { + const message = { + userId: "testuser", + content: "Hello everyone!", + type: "message", + }; + const globalSettings = { + enableNotificationSounds: true, + enableHighlights: true, + customMentions: [], + }; + + const result = shouldPlayNotificationSound( + message, + mockCurrentUser, + globalSettings, + ); + expect(result).toBe(false); + }); + + it("should return true for mentions when highlights are enabled", () => { + const message = { + userId: "otheruser", + content: "Hello testuser!", + type: "message", + }; + const globalSettings = { + enableNotificationSounds: true, + enableHighlights: true, + customMentions: [], + }; + + const result = shouldPlayNotificationSound( + message, + mockCurrentUser, + globalSettings, + ); + expect(result).toBe(true); + }); + + it("should return false for non-mentions when highlights are enabled", () => { + const message = { + userId: "otheruser", + content: "Hello everyone!", + type: "message", + }; + const globalSettings = { + enableNotificationSounds: true, + enableHighlights: true, + customMentions: [], + }; + + const result = shouldPlayNotificationSound( + message, + mockCurrentUser, + globalSettings, + ); + expect(result).toBe(false); + }); + + it("should return true for all messages when highlights are disabled", () => { + const message = { + userId: "otheruser", + content: "Hello everyone!", + type: "message", + }; + const globalSettings = { + enableNotificationSounds: true, + enableHighlights: false, + customMentions: [], + }; + + const result = shouldPlayNotificationSound( + message, + mockCurrentUser, + globalSettings, + ); + expect(result).toBe(true); + }); + + it("should detect mentions case-insensitively", () => { + const message = { + userId: "otheruser", + content: "Hello TESTUSER!", + type: "message", + }; + const globalSettings = { + enableNotificationSounds: true, + enableHighlights: true, + customMentions: [], + }; + + const result = shouldPlayNotificationSound( + message, + mockCurrentUser, + globalSettings, + ); + expect(result).toBe(true); + }); + + it("should handle null current user gracefully", () => { + const message = { + userId: "otheruser", + content: "Hello everyone!", + type: "message", + }; + const globalSettings = { + enableNotificationSounds: true, + enableHighlights: true, + customMentions: [], + }; + + const result = shouldPlayNotificationSound(message, null, globalSettings); + expect(result).toBe(true); + }); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index 11e5c1c1..4c1e70d4 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -6,4 +6,4 @@ window.matchMedia = vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn(), -})) as unknown as MediaQueryList; +})) as unknown as (query: string) => MediaQueryList; diff --git a/tsconfig.json b/tsconfig.json index da9a4b4e..8c573b83 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,10 +12,12 @@ "jsx": "react-jsx", "strict": true, "noFallthroughCasesInSwitch": true, - "noEmit": true + "noEmit": true, + "types": ["vitest/globals", "@testing-library/jest-dom"] }, "include": [ "src", - "vite.config.ts" + "vite.config.ts", + "tests" ] } diff --git a/vite.config.ts b/vite.config.ts index d0b5dd98..6cd82995 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,5 @@ /// +/// import { defineConfig, loadEnv } from 'vite'; import react from "@vitejs/plugin-react";