diff --git a/src-tauri/src/connection_manager.rs b/src-tauri/src/connection_manager.rs index b21ccdf..e0c0aeb 100644 --- a/src-tauri/src/connection_manager.rs +++ b/src-tauri/src/connection_manager.rs @@ -4,6 +4,7 @@ use tokio::sync::Mutex; use tauri::{AppHandle, Emitter}; use irc::client::prelude::*; use futures::stream::StreamExt; +use crate::message_formatter::format_message; pub struct ConnectionManager { pub client: Option>>, @@ -57,7 +58,7 @@ impl ConnectionManager { async move { let mut stream = arc_client.lock().await.stream().unwrap(); // <-- .await for Tokio Mutex while let Some(message) = stream.next().await.transpose().unwrap() { - let msg_str = format!("{:?}", message); + let msg_str = format_message(&message); let _ = app_handle.emit("irc-message", msg_str); } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0188e73..84a4114 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ mod connection_manager; mod command; // now it exists +mod message_formatter; use connection_manager::ConnectionManager; use command::{ diff --git a/src-tauri/src/message_formatter.rs b/src-tauri/src/message_formatter.rs new file mode 100644 index 0000000..1d2f6f1 --- /dev/null +++ b/src-tauri/src/message_formatter.rs @@ -0,0 +1,197 @@ +use irc::client::prelude::*; + +/// Escape HTML special characters +fn escape_html(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +/// Format an IRC message with HTML colors and beautification +pub fn format_message(message: &Message) -> String { + // Extract prefix (server or user) + let prefix_str = match &message.prefix { + Some(Prefix::ServerName(server)) => { + format!("{}", escape_html(server)) + }, + Some(Prefix::Nickname(nick, user, host)) => { + if !host.is_empty() { + format!("{}@{}", + escape_html(nick), escape_html(host)) + } else if !user.is_empty() { + format!("{}@{}", + escape_html(nick), escape_html(user)) + } else { + format!("{}", escape_html(nick)) + } + }, + None => String::new(), + }; + + // Format based on command type + match &message.command { + Command::NOTICE(target, text) => { + format!("[NOTICE] {}{}: {}", + prefix_str, + escape_html(target), + escape_html(text)) + }, + Command::PRIVMSG(target, text) => { + format!("{}{}: {}", + prefix_str, + escape_html(target), + escape_html(text)) + }, + Command::JOIN(channel, keys, _) => { + let keys_str = if let Some(k) = keys { + format!(" (key: {})", escape_html(k)) + } else { + String::new() + }; + format!("{} joined {}{}", + prefix_str, + escape_html(channel), + keys_str) + }, + Command::PART(channel, msg) => { + let msg_str = if let Some(m) = msg { + format!(" ({})", escape_html(m)) + } else { + String::new() + }; + format!("{} left {}{}", + prefix_str, + escape_html(channel), + msg_str) + }, + Command::QUIT(msg) => { + let msg_str = if let Some(m) = msg { + format!(" ({})", escape_html(m)) + } else { + String::new() + }; + format!("{} quit{}", + prefix_str, + msg_str) + }, + Command::NICK(new_nick) => { + format!("{} is now known as {}", + prefix_str, + escape_html(new_nick)) + }, + Command::TOPIC(channel, topic) => { + if let Some(t) = topic { + format!("{}Topic for {}: {}", + prefix_str, + escape_html(channel), + escape_html(t)) + } else { + format!("{}Topic for {} cleared", + prefix_str, + escape_html(channel)) + } + }, + Command::KICK(channel, user, msg) => { + let msg_str = if let Some(m) = msg { + format!(" ({})", escape_html(m)) + } else { + String::new() + }; + format!("{} kicked {} from {}{}", + prefix_str, + escape_html(user), + escape_html(channel), + msg_str) + }, + Command::Response(code, params) => { + // Special handling for RPL_NAMREPLY (353) - format user list nicely + if let Response::RPL_NAMREPLY = code { + if params.len() >= 4 { + let channel = ¶ms[2]; + let users_str = ¶ms[3]; + // Split users and format them + let users: Vec<&str> = users_str.split_whitespace().collect(); + let formatted_users: Vec = users.iter() + .map(|u| { + // Check for channel prefixes (@ for ops, + for voiced) + if u.starts_with('@') { + format!("{}", escape_html(u)) + } else if u.starts_with('+') { + format!("{}", escape_html(u)) + } else { + format!("{}", escape_html(u)) + } + }) + .collect(); + + return format!("{}[NAMES] {}: {}", + prefix_str, + escape_html(channel), + formatted_users.join(" ")); + } + } + + // Special handling for RPL_ENDOFNAMES (366) + if let Response::RPL_ENDOFNAMES = code { + if params.len() >= 2 { + let channel = ¶ms[1]; + return format!("{}[ENDOFNAMES] {}: End of /NAMES list.", + prefix_str, + escape_html(channel)); + } + } + + let code_str = format!("{:?}", code); + let params_str = params.iter() + .map(|p| format!("{}", escape_html(p))) + .collect::>() + .join(" "); + format!("{}[{}] {}", + prefix_str, + escape_html(&code_str), + params_str) + }, + Command::PING(server1, server2) => { + let servers = if let Some(s2) = server2 { + format!("{}, {}", server1, s2) + } else { + server1.clone() + }; + format!("[PING] {}", + escape_html(&servers)) + }, + Command::PONG(server1, server2) => { + let servers = if let Some(s2) = server2 { + format!("{}, {}", server1, s2) + } else { + server1.clone() + }; + format!("[PONG] {}", + escape_html(&servers)) + }, + Command::INVITE(user, channel) => { + format!("{} invited {} to {}", + prefix_str, + escape_html(user), + escape_html(channel)) + }, + Command::Raw(code, params) => { + let params_str = params.iter() + .map(|p| format!("{}", escape_html(p))) + .collect::>() + .join(" "); + format!("{}[RAW {}] {}", + prefix_str, + code, + params_str) + }, + _ => { + // Fallback for unhandled commands + format!("{}[{:?}]", + prefix_str, + message.command) + } + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4cb5ecc..d828e27 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -11,8 +11,8 @@ "windows": [ { "title": "ircd", - "width": 800, - "height": 600 + "width": 860, + "height": 800 } ], "security": { diff --git a/src/UI.js b/src/UI.js index 6587298..e817e0c 100644 --- a/src/UI.js +++ b/src/UI.js @@ -27,14 +27,28 @@ const uiState = { activeTab: null, channels: [], collapsedSidebar: false, + collapsedPeopleSidebar: false, userColors: new Map(), - currentUser: 'IRCDUser' + currentUser: 'IRCDUser', + channelUsers: new Map(), // Map> + channelMessages: new Map(), // Map> + peopleSearchQuery: '', // Current search query for people list + virtualScrollState: { + startIndex: 0, + endIndex: 50, // Initial visible range + itemHeight: 30, // Approximate height of each people item (padding + margin + content) + containerHeight: 0 + }, + filteredUsers: [] // Filtered users list for virtual scrolling }; // DOM Elements const sidebar = document.getElementById('channels'); const sidebarResizeHandle = document.getElementById('sidebarResizeHandle'); const toggleSidebarBtn = document.getElementById('toggleSidebar'); +const peopleSidebar = document.getElementById('peopleSidebar'); +const peopleSidebarResizeHandle = document.getElementById('peopleSidebarResizeHandle'); +const togglePeopleSidebarBtn = document.getElementById('togglePeopleSidebar'); const connectBtn = document.getElementById('connectBtn'); const disconnectBtn = document.getElementById('disconnectBtn'); const joinBtn = document.getElementById('joinBtn'); @@ -45,6 +59,10 @@ const statusText = document.getElementById('status'); const tabBar = document.getElementById('tabBar'); const messagesContainer = document.getElementById('messages'); const channelList = document.getElementById('channelList'); +const peopleList = document.getElementById('peopleList'); +const peopleListContainer = document.getElementById('peopleListContainer'); +const peopleListSpacer = document.getElementById('peopleListSpacer'); +const peopleSearchInput = document.getElementById('peopleSearch'); const messageInput = document.getElementById('messageInput'); const channelInput = document.getElementById('newChannel'); const commandSuggestions = document.getElementById('commandSuggestions'); @@ -67,6 +85,7 @@ function initUI() { // Add event listeners toggleSidebarBtn.addEventListener('click', toggleSidebar); + togglePeopleSidebarBtn.addEventListener('click', togglePeopleSidebar); connectBtn.addEventListener('click', handleConnect); disconnectBtn.addEventListener('click', handleDisconnect); joinBtn.addEventListener('click', handleJoin); @@ -74,9 +93,23 @@ function initUI() { sendBtn.addEventListener('click', handleSend); messageInput.addEventListener('keydown', handleMessageInput); channelInput.addEventListener('keydown', handleChannelInput); + peopleSearchInput.addEventListener('input', handlePeopleSearch); + peopleListContainer.addEventListener('scroll', handlePeopleListScroll); // Set up sidebar resizing setupSidebarResize(); + setupPeopleSidebarResize(); + + // Initialize virtual scroll container height + updateVirtualScrollContainerHeight(); + + // Watch for container resize + if (peopleListContainer && ResizeObserver) { + const resizeObserver = new ResizeObserver(() => { + updateVirtualScrollContainerHeight(); + }); + resizeObserver.observe(peopleListContainer); + } // Set active tab setActiveTab('welcome'); @@ -111,6 +144,35 @@ function setupSidebarResize() { }); } +// Setup people sidebar resizing +function setupPeopleSidebarResize() { + let isResizing = false; + + peopleSidebarResizeHandle.addEventListener('mousedown', (e) => { + isResizing = true; + peopleSidebarResizeHandle.classList.add('resizing'); + document.body.style.cursor = 'col-resize'; + e.preventDefault(); + }); + + document.addEventListener('mousemove', (e) => { + if (!isResizing) return; + + const newWidth = window.innerWidth - e.clientX; + if (newWidth > 200 && newWidth < 500) { + peopleSidebar.style.width = `${newWidth}px`; + } + }); + + document.addEventListener('mouseup', () => { + if (isResizing) { + isResizing = false; + peopleSidebarResizeHandle.classList.remove('resizing'); + document.body.style.cursor = ''; + } + }); +} + // Toggle sidebar function toggleSidebar() { uiState.collapsedSidebar = !uiState.collapsedSidebar; @@ -125,6 +187,20 @@ function toggleSidebar() { } } +// Toggle people sidebar +function togglePeopleSidebar() { + uiState.collapsedPeopleSidebar = !uiState.collapsedPeopleSidebar; + peopleSidebar.classList.toggle('collapsed', uiState.collapsedPeopleSidebar); + + // Update toggle button icon + const icon = togglePeopleSidebarBtn.querySelector('svg'); + if (uiState.collapsedPeopleSidebar) { + icon.innerHTML = ''; + } else { + icon.innerHTML = ''; + } +} + // Get color for a user function getUserColor(username) { if (!uiState.userColors.has(username)) { @@ -195,26 +271,44 @@ function handlePart() { } function addChannel(name) { - if (!uiState.channels.includes(name)) { - uiState.channels.push(name); + // Normalize channel name to always start with # + let normalizedName = name; + if (!normalizedName.startsWith('#')) { + normalizedName = '#' + normalizedName; + } + + if (!uiState.channels.includes(normalizedName)) { + uiState.channels.push(normalizedName); - // Add to channel list - const channelItem = document.createElement('div'); - channelItem.className = 'channel-item'; - channelItem.innerHTML = ` -
#
-
${name}
- `; - channelItem.addEventListener('click', () => setActiveTab(name)); - channelList.appendChild(channelItem); + // Add to channel list (async DOM update) + requestAnimationFrame(() => { + const channelItem = document.createElement('div'); + channelItem.className = 'channel-item'; + channelItem.innerHTML = ` +
+ + + + +
+
${escapeHtml(normalizedName)}
+ `; + channelItem.addEventListener('click', () => setActiveTab(normalizedName)); + channelList.appendChild(channelItem); + }); - // Add tab - addTab(name); + // Add tab (async) + requestAnimationFrame(() => addTab(normalizedName)); + + // Initialize message storage for this channel + if (!uiState.channelMessages.has(normalizedName)) { + uiState.channelMessages.set(normalizedName, []); + } // Auto-join the channel when connected if (uiState.connected) { - invoke('join_channel', { channel: name }).catch(e => { - addSystemMessage(`Failed to join ${name}: ${e}`); + invoke('join_channel', { channel: normalizedName }).catch(e => { + addSystemMessage(`Failed to join ${normalizedName}: ${e}`); }); } } @@ -233,6 +327,12 @@ function removeChannel(name) { } }); + // Clear users for this channel + clearChannelUsers(name); + + // Clear messages for this channel (optional - you might want to keep them) + // uiState.channelMessages.delete(name); + // Remove tab removeTab(name); @@ -247,24 +347,30 @@ function removeChannel(name) { // Tab management function addTab(name) { + // Normalize channel name + let normalizedName = name; + if (name !== 'welcome' && !name.startsWith('#')) { + normalizedName = '#' + name; + } + const tab = document.createElement('div'); tab.className = 'tab'; - tab.dataset.tab = name; + tab.dataset.tab = normalizedName; tab.innerHTML = ` - ${name} + ${normalizedName} × `; tab.addEventListener('click', (e) => { if (e.target.classList.contains('tab-close')) { - removeTab(name); + removeTab(normalizedName); } else { - setActiveTab(name); + setActiveTab(normalizedName); } }); tabBar.appendChild(tab); - setActiveTab(name); + setActiveTab(normalizedName); } function removeTab(name) { @@ -284,35 +390,121 @@ function removeTab(name) { } function setActiveTab(name) { - uiState.activeTab = name; + // Normalize channel name to ensure consistency + let normalizedName = name; + if (name !== 'welcome' && !name.startsWith('#')) { + normalizedName = '#' + name; + } - // Update tabs - const tabs = tabBar.querySelectorAll('.tab'); - tabs.forEach(tab => { - tab.classList.toggle('active', tab.dataset.tab === name); + uiState.activeTab = normalizedName; + + // Update tabs (async) + requestAnimationFrame(() => { + const tabs = tabBar.querySelectorAll('.tab'); + tabs.forEach(tab => { + // Compare with both normalized and original name + tab.classList.toggle('active', tab.dataset.tab === normalizedName || tab.dataset.tab === name); + }); }); - // Update channels - const channelItems = channelList.querySelectorAll('.channel-item'); - channelItems.forEach(item => { - const channelName = item.querySelector('.channel-name').textContent; - item.classList.toggle('active', channelName === name); + // Update channels (async) + requestAnimationFrame(() => { + const channelItems = channelList.querySelectorAll('.channel-item'); + channelItems.forEach(item => { + const channelName = item.querySelector('.channel-name').textContent; + item.classList.toggle('active', channelName === normalizedName || channelName === name); + }); }); - // Update messages - updateMessagesForTab(name); + // Update people list for the active channel (async) + updatePeopleList(normalizedName); + + // Update messages (async) + requestAnimationFrame(() => { + updateMessagesForTab(normalizedName); + }); } function updateMessagesForTab(name) { // Clear messages messagesContainer.innerHTML = ''; - // Add appropriate messages for the tab - if (name === 'welcome') { - addSystemMessage('IRCD - Welcome! SystemTime: ' + new Date().toLocaleString()); + // Ensure channel name format is correct + let channelKey = name; + if (name !== 'welcome' && !name.startsWith('#')) { + channelKey = '#' + name; + } + + // Load stored messages from RAM + const storedMessages = uiState.channelMessages.get(channelKey); + if (storedMessages && storedMessages.length > 0) { + // Restore all stored messages (only once, not duplicated) + requestAnimationFrame(() => { + // Use a Set to track displayed messages and prevent duplicates + const displayedHashes = new Set(); + + storedMessages.forEach(msg => { + // Create a hash to detect duplicates + const msgHash = `${msg.type}-${msg.timestamp}-${msg.content.substring(0, 50)}`; + + if (displayedHashes.has(msgHash)) { + return; // Skip duplicate + } + displayedHashes.add(msgHash); + + if (msg.html) { + // For IRC messages, the HTML is the full formatted content + // The HTML from backend is a string of spans, so we wrap it in a div + const ircMessage = document.createElement('div'); + ircMessage.className = 'irc-message'; + + // Use the stored HTML (which is the full formatted content from backend) + // If raw exists, prefer it, otherwise use html + const htmlContent = msg.raw || msg.html; + ircMessage.innerHTML = htmlContent; + + messagesContainer.appendChild(ircMessage); + } else if (msg.type === 'message') { + // Recreate message element if HTML not stored + const messageLine = document.createElement('div'); + messageLine.className = 'message-line'; + + const senderElement = document.createElement('div'); + senderElement.className = `message-sender ${msg.isUser ? 'you' : ''}`; + senderElement.textContent = msg.isUser ? `${msg.sender} (You):` : `${msg.sender}:`; + senderElement.style.color = msg.isUser ? '' : getUserColor(msg.sender); + + const contentElement = document.createElement('div'); + contentElement.className = 'message-content'; + contentElement.textContent = msg.content; + + messageLine.appendChild(senderElement); + messageLine.appendChild(contentElement); + messagesContainer.appendChild(messageLine); + } else if (msg.type === 'system') { + const systemMessage = document.createElement('div'); + systemMessage.className = 'system-message'; + systemMessage.textContent = msg.content; + messagesContainer.appendChild(systemMessage); + } + }); + scrollToBottom(); + }); } else { - addSystemMessage(`Welcome to #${name}!`); - addSystemMessage('This is the beginning of the channel.'); + // Add welcome messages for new channels (only if no stored messages) + if (name === 'welcome') { + // Don't store welcome messages, just display + requestAnimationFrame(() => { + const welcomeMsg = document.createElement('div'); + welcomeMsg.className = 'system-message'; + welcomeMsg.textContent = 'IRCD - Welcome! SystemTime: ' + new Date().toLocaleString(); + messagesContainer.appendChild(welcomeMsg); + }); + } else if (channelKey !== 'welcome') { + // Only add welcome message if channel has no messages + addSystemMessage(`Welcome to ${channelKey}!`); + addSystemMessage('This is the beginning of the channel.'); + } } } @@ -350,41 +542,292 @@ function handleCommand(command) { } function addMessage(sender, content, isUser = false) { - const messageLine = document.createElement('div'); - messageLine.className = 'message-line'; + const channel = uiState.activeTab; + if (!channel || channel === 'welcome') return; - const senderElement = document.createElement('div'); - senderElement.className = `message-sender ${isUser ? 'you' : ''}`; - senderElement.textContent = isUser ? `${sender} (You):` : `${sender}:`; - senderElement.style.color = isUser ? '' : getUserColor(sender); + // Normalize channel name + let channelKey = channel; + if (!channelKey.startsWith('#')) { + channelKey = '#' + channelKey; + } - const contentElement = document.createElement('div'); - contentElement.className = 'message-content'; - contentElement.textContent = content; + // Store message in RAM - ALWAYS store + if (!uiState.channelMessages.has(channelKey)) { + uiState.channelMessages.set(channelKey, []); + } - messageLine.appendChild(senderElement); - messageLine.appendChild(contentElement); - - messagesContainer.appendChild(messageLine); - scrollToBottom(); + // Use requestAnimationFrame for async DOM updates + requestAnimationFrame(() => { + const messageLine = document.createElement('div'); + messageLine.className = 'message-line'; + + const senderElement = document.createElement('div'); + senderElement.className = `message-sender ${isUser ? 'you' : ''}`; + senderElement.textContent = isUser ? `${sender} (You):` : `${sender}:`; + senderElement.style.color = isUser ? '' : getUserColor(sender); + + const contentElement = document.createElement('div'); + contentElement.className = 'message-content'; + contentElement.textContent = content; + + messageLine.appendChild(senderElement); + messageLine.appendChild(contentElement); + + // Store FULL message data with HTML + const messageData = { + type: 'message', + sender, + content, + isUser, + timestamp: Date.now(), + html: messageLine.outerHTML, // Full HTML content + raw: messageLine.outerHTML, // Keep raw HTML + formatted: true // Mark as formatted + }; + + uiState.channelMessages.get(channelKey).push(messageData); + console.log(`[STORE] Stored FULL user message in ${channelKey} (${content.substring(0, 50)}...)`); + + // Update activeTab to normalized name for consistency + if (uiState.activeTab !== channelKey) { + uiState.activeTab = channelKey; + } + + messagesContainer.appendChild(messageLine); + scrollToBottom(); + }); } function addSystemMessage(content) { - const systemMessage = document.createElement('div'); - systemMessage.className = 'system-message'; - systemMessage.textContent = content; + const channel = uiState.activeTab; + if (!channel || channel === 'welcome') return; - messagesContainer.appendChild(systemMessage); - scrollToBottom(); + // Normalize channel name + let channelKey = channel; + if (!channelKey.startsWith('#')) { + channelKey = '#' + channelKey; + } + + // Store message in RAM - ALWAYS store + if (!uiState.channelMessages.has(channelKey)) { + uiState.channelMessages.set(channelKey, []); + } + + // Use requestAnimationFrame for async DOM updates + requestAnimationFrame(() => { + const systemMessage = document.createElement('div'); + systemMessage.className = 'system-message'; + systemMessage.textContent = content; + + // Store FULL message data with HTML + const messageData = { + type: 'system', + sender: null, + content, + isUser: false, + timestamp: Date.now(), + html: systemMessage.outerHTML, // Full HTML content + raw: systemMessage.outerHTML, // Keep raw HTML + formatted: true // Mark as formatted + }; + + uiState.channelMessages.get(channelKey).push(messageData); + console.log(`[STORE] Stored FULL system message in ${channelKey} (${content.substring(0, 50)}...)`); + + // Update activeTab to normalized name for consistency + if (uiState.activeTab !== channelKey) { + uiState.activeTab = channelKey; + } + + messagesContainer.appendChild(systemMessage); + scrollToBottom(); + }); } function addIRCMessage(content) { - const ircMessage = document.createElement('div'); - ircMessage.className = 'irc-message'; - ircMessage.textContent = content; + // Parse the message to determine which channel it belongs to + let targetChannel = null; + let joinMatch = null; + let partMatch = null; - messagesContainer.appendChild(ircMessage); - scrollToBottom(); + // Extract plain text for checking + const plainText = content.replace(/<[^>]*>/g, ''); + + // Check if this is a server notice (starts with server name, no channel) + const isServerNotice = /^[a-zA-Z0-9.-]+\s+\[NOTICE\]/.test(plainText) || + /^[a-zA-Z0-9.-]+\s+NOTICE/.test(plainText); + + // Try to extract channel from PRIVMSG + // PRIVMSG format: : + const privmsgMatch = content.match(/→\s*]*>([^<]+)<\/span>]*>:\s*<\/span>/); + if (privmsgMatch) { + targetChannel = privmsgMatch[1].trim(); + } + + // NOTICE format: [NOTICE] : + // Only if target is a channel (starts with #) + const noticeMatch = content.match(/\[NOTICE\].*?→\s*]*>([^<]+)<\/span>]*>:\s*<\/span>/); + if (noticeMatch) { + const target = noticeMatch[1].trim(); + // Only treat as channel message if target starts with # + if (target.startsWith('#')) { + targetChannel = target; + } else if (!isServerNotice) { + // If it's a user notice, check if we're in a PM (future feature) + // For now, skip storing user notices + } + } + + // JOIN format: joined + joinMatch = content.match(/joined\s*]*>([^<]+)<\/span>/); + if (joinMatch) { + targetChannel = joinMatch[1].trim(); + // Extract sender name from the beginning of the message + const senderMatch = plainText.match(/^([^\s]+)\s+joined/); + if (senderMatch) { + const sender = senderMatch[1]; + console.log(`[JOIN] ${sender} joined ${targetChannel}`); + } + } + + // PART format: left + partMatch = content.match(/left\s*]*>([^<]+)<\/span>/); + if (partMatch) { + targetChannel = partMatch[1].trim(); + // Extract sender name from the beginning of the message + const senderMatch = plainText.match(/^([^\s]+)\s+left/); + if (senderMatch) { + const sender = senderMatch[1]; + console.log(`[PART] ${sender} left ${targetChannel}`); + } + } + + // QUIT format: quit (reason) + const quitMatch = plainText.match(/^([^\s]+)\s+quit/); + if (quitMatch) { + // QUIT messages don't have a channel, but we should still display them + const sender = quitMatch[1]; + console.log(`[QUIT] ${sender} quit`); + } + + // NICK format: is now known as + const nickMatch = plainText.match(/^([^\s]+)\s+is now known as\s+([^\s]+)/); + if (nickMatch) { + const sender = nickMatch[1]; + const newNick = nickMatch[2]; + console.log(`[NICK] ${sender} is now known as ${newNick}`); + } + + // NAMES format: [NAMES] : + const namesMatch = content.match(/\[NAMES\]\s*<\/span>\s*]*>([^<]+)<\/span>/); + if (namesMatch) { + targetChannel = namesMatch[1].trim(); + } + + // Normalize channel name + if (targetChannel && !targetChannel.startsWith('#')) { + targetChannel = '#' + targetChannel; + } + + // Normalize active channel for comparison + let activeChannel = uiState.activeTab; + if (activeChannel && activeChannel !== 'welcome' && !activeChannel.startsWith('#')) { + activeChannel = '#' + activeChannel; + } + + // Determine where to store the message - SIMPLE AND RELIABLE + const isJoinOrPart = joinMatch || partMatch; + + // Always store in active channel (where user is viewing) + // This ensures all messages seen by the user are stored + let storeChannel = activeChannel || 'welcome'; + + // If message has a specific target channel (PRIVMSG, JOIN, PART, etc.), also store there + // But primary storage is always the active channel + if (targetChannel && targetChannel.startsWith('#')) { + // Store in both the target channel AND the active channel + // This way messages are available when switching channels + const channelsToStore = [storeChannel, targetChannel]; + + channelsToStore.forEach(ch => { + if (ch && ch !== 'welcome') { + if (!uiState.channelMessages.has(ch)) { + uiState.channelMessages.set(ch, []); + } + + const existingMessages = uiState.channelMessages.get(ch); + const now = Date.now(); + const duplicateWindow = isJoinOrPart ? 5000 : 2000; + + // Check for duplicates + const isDuplicate = existingMessages.some(msg => + msg.type === 'irc' && + msg.content === plainText && + Math.abs(msg.timestamp - now) < duplicateWindow + ); + + if (!isDuplicate) { + const messageData = { + type: 'irc', + sender: null, + content: plainText, + isUser: false, + timestamp: now, + html: content, // Full HTML content + raw: content, // Keep raw HTML + formatted: true // Mark as formatted + }; + + existingMessages.push(messageData); + console.log(`[STORE] Stored FULL message in ${ch} (${plainText.substring(0, 50)}...)`); + } + } + }); + } else { + // No target channel - store only in active channel + if (storeChannel && storeChannel !== 'welcome') { + if (!uiState.channelMessages.has(storeChannel)) { + uiState.channelMessages.set(storeChannel, []); + } + + const existingMessages = uiState.channelMessages.get(storeChannel); + const now = Date.now(); + const duplicateWindow = isJoinOrPart ? 5000 : 2000; + + const isDuplicate = existingMessages.some(msg => + msg.type === 'irc' && + msg.content === plainText && + Math.abs(msg.timestamp - now) < duplicateWindow + ); + + if (!isDuplicate) { + const messageData = { + type: 'irc', + sender: null, + content: plainText, + isUser: false, + timestamp: now, + html: content, // Full HTML content + raw: content, // Keep raw HTML + formatted: true // Mark as formatted + }; + + existingMessages.push(messageData); + console.log(`[STORE] Stored FULL message in ${storeChannel} (no target channel) (${plainText.substring(0, 50)}...)`); + } + } + } + + // Display ALL messages in the active channel - no filtering + // Always display, regardless of target channel + requestAnimationFrame(() => { + const ircMessage = document.createElement('div'); + ircMessage.className = 'irc-message'; + ircMessage.innerHTML = content; // Use innerHTML to render HTML formatting + + messagesContainer.appendChild(ircMessage); + scrollToBottom(); + }); } function scrollToBottom() { @@ -496,5 +939,248 @@ function extractNickname(prefix) { return 'Unknown'; } +// People list management with virtual scrolling and search +function updatePeopleList(channel) { + // Only show people for channels (not welcome tab) + if (channel === 'welcome' || !channel.startsWith('#')) { + if (peopleList) peopleList.innerHTML = ''; + if (peopleListSpacer) peopleListSpacer.style.height = '0px'; + uiState.filteredUsers = []; + return; + } + + // Get users for this channel (stored in RAM) + const users = uiState.channelUsers.get(channel); + if (!users || users.size === 0) { + if (peopleList) peopleList.innerHTML = ''; + if (peopleListSpacer) peopleListSpacer.style.height = '0px'; + uiState.filteredUsers = []; + return; + } + + // Sort and filter users (all stored in RAM, not DOM) + setTimeout(() => { + let sortedUsers = Array.from(users).sort((a, b) => { + const aIsOp = a.startsWith('@'); + const bIsOp = b.startsWith('@'); + const aIsVoiced = a.startsWith('+'); + const bIsVoiced = b.startsWith('+'); + + if (aIsOp && !bIsOp) return -1; + if (!aIsOp && bIsOp) return 1; + if (aIsVoiced && !bIsVoiced) return -1; + if (!aIsVoiced && bIsVoiced) return 1; + return a.localeCompare(b, undefined, { sensitivity: 'base' }); + }); + + // Apply search filter + const searchQuery = uiState.peopleSearchQuery.toLowerCase(); + if (searchQuery) { + sortedUsers = sortedUsers.filter(user => { + const displayName = (user.startsWith('@') || user.startsWith('+')) + ? user.substring(1).toLowerCase() + : user.toLowerCase(); + return displayName.includes(searchQuery); + }); + } + + // Store filtered list for virtual scrolling (in RAM) + uiState.filteredUsers = sortedUsers; + + // Reset scroll position when filtering + if (searchQuery && peopleListContainer) { + peopleListContainer.scrollTop = 0; + } + + // Update virtual scroll - only renders visible items + renderVisibleUsers(); + }, 0); +} + +function renderVisibleUsers() { + if (!uiState.filteredUsers || !peopleListContainer) return; + + const { itemHeight } = uiState.virtualScrollState; + let containerHeight = uiState.virtualScrollState.containerHeight; + + // Get container height if not set + if (!containerHeight || containerHeight === 0) { + containerHeight = peopleListContainer.clientHeight; + if (containerHeight === 0) { + // Container not ready, try again later + setTimeout(() => renderVisibleUsers(), 50); + return; + } + uiState.virtualScrollState.containerHeight = containerHeight; + } + + const scrollTop = peopleListContainer.scrollTop || 0; + const totalItems = uiState.filteredUsers.length; + + // Calculate visible range with buffer + const buffer = 10; // Render 10 extra items above and below for smooth scrolling + const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - buffer); + const visibleCount = Math.ceil(containerHeight / itemHeight) + (buffer * 2); + const endIndex = Math.min(totalItems, startIndex + visibleCount); + + uiState.virtualScrollState.startIndex = startIndex; + uiState.virtualScrollState.endIndex = endIndex; + + // Update spacer height for items before visible range + const topSpacerHeight = startIndex * itemHeight; + if (peopleListSpacer) { + peopleListSpacer.style.height = `${topSpacerHeight}px`; + } + + // Set total height for proper scrolling + const totalHeight = totalItems * itemHeight; + if (peopleList) { + peopleList.style.minHeight = `${totalHeight}px`; + } + + // Clear and render only visible items + requestAnimationFrame(() => { + if (!peopleList) return; + + peopleList.innerHTML = ''; + const fragment = document.createDocumentFragment(); + + for (let i = startIndex; i < endIndex; i++) { + const user = uiState.filteredUsers[i]; + const peopleItem = createPeopleItem(user); + fragment.appendChild(peopleItem); + } + + peopleList.appendChild(fragment); + }); +} + +function createPeopleItem(user) { + const peopleItem = document.createElement('div'); + peopleItem.className = 'people-item'; + + let prefix = ''; + let displayName = user; + let iconColor = 'currentColor'; + + if (user.startsWith('@')) { + peopleItem.classList.add('op'); + prefix = '@'; + displayName = user.substring(1); + iconColor = '#ff5f87'; + } else if (user.startsWith('+')) { + peopleItem.classList.add('voiced'); + prefix = '+'; + displayName = user.substring(1); + iconColor = '#5af78e'; + } + + peopleItem.innerHTML = ` +
+ + + + +
+ ${prefix ? `${prefix}` : ''} + ${escapeHtml(displayName)} + `; + + return peopleItem; +} + +function handlePeopleSearch(e) { + uiState.peopleSearchQuery = e.target.value; + const channel = uiState.activeTab; + if (channel) { + updatePeopleList(channel); + } +} + +function handlePeopleListScroll() { + requestAnimationFrame(() => { + renderVisibleUsers(); + }); +} + +function updateVirtualScrollContainerHeight() { + requestAnimationFrame(() => { + if (peopleListContainer) { + uiState.virtualScrollState.containerHeight = peopleListContainer.clientHeight; + renderVisibleUsers(); + } + }); +} + +function parseNamesMessage(channel, usersStr) { + if (!channel || !usersStr) return; + + // Process parsing asynchronously + setTimeout(() => { + // Initialize users set for this channel if it doesn't exist + // Note: We accumulate users from multiple NAMES messages until ENDOFNAMES + // Users are stored in RAM, not DOM + if (!uiState.channelUsers.has(channel)) { + uiState.channelUsers.set(channel, new Set()); + } + + const users = usersStr.trim().split(/\s+/).filter(u => u && u.trim().length > 0); + const channelUsers = uiState.channelUsers.get(channel); + + // Add all users from this NAMES message to RAM + users.forEach(user => { + const trimmed = user.trim(); + if (trimmed) { + channelUsers.add(trimmed); + } + }); + + // Update the people list if this is the active channel (async) + // This will use virtual scrolling to only render visible users + if (uiState.activeTab === channel) { + updatePeopleList(channel); + } + }, 0); +} + +function clearChannelUsers(channel) { + uiState.channelUsers.delete(channel); + if (uiState.activeTab === channel) { + updatePeopleList(channel); + } +} + +function addUserToChannel(channel, user) { + if (!uiState.channelUsers.has(channel)) { + uiState.channelUsers.set(channel, new Set()); + } + uiState.channelUsers.get(channel).add(user); + if (uiState.activeTab === channel) { + updatePeopleList(channel); + } +} + +function removeUserFromChannel(channel, user) { + const channelUsers = uiState.channelUsers.get(channel); + if (channelUsers) { + // Try with and without prefixes + channelUsers.delete(user); + channelUsers.delete('@' + user); + channelUsers.delete('+' + user); + if (user.startsWith('@') || user.startsWith('+')) { + channelUsers.delete(user.substring(1)); + } + if (uiState.activeTab === channel) { + updatePeopleList(channel); + } + } +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + // Initialize the UI when the page loads document.addEventListener('DOMContentLoaded', initUI); \ No newline at end of file diff --git a/src/index.html b/src/index.html index 7f9738c..b11a917 100644 --- a/src/index.html +++ b/src/index.html @@ -23,7 +23,14 @@
-

Connection

+

+ + + + + + Connection +

@@ -47,7 +54,12 @@
-

Channels

+

+ + + + Channels +

@@ -83,6 +95,40 @@ + + +
diff --git a/src/styles/styles.css b/src/styles/styles.css index 0207b64..6d63bd7 100644 --- a/src/styles/styles.css +++ b/src/styles/styles.css @@ -170,6 +170,13 @@ body { min-width: 200px; max-width: 500px; resize: horizontal; + flex-shrink: 0; +} + +.sidebar-right { + border-right: none; + border-left: 1px solid var(--border-color); + order: 3; } .sidebar-resize-handle { @@ -183,6 +190,11 @@ body { z-index: 10; } +.sidebar-resize-handle-left { + right: auto; + left: 0; +} + .sidebar-resize-handle:hover, .sidebar-resize-handle.resizing { background: var(--primary-color); @@ -190,6 +202,8 @@ body { .sidebar.collapsed { width: var(--sidebar-collapsed-width); + min-width: var(--sidebar-collapsed-width); + max-width: var(--sidebar-collapsed-width); } .sidebar-header { @@ -237,6 +251,14 @@ body { text-transform: uppercase; letter-spacing: 0.5px; white-space: nowrap; + display: flex; + align-items: center; + gap: 6px; +} + +.section-icon { + flex-shrink: 0; + opacity: 0.7; } .form-group { @@ -354,6 +376,13 @@ button:disabled { display: flex; align-items: center; justify-content: center; + flex-shrink: 0; + color: var(--text-muted); +} + +.channel-icon svg { + width: 100%; + height: 100%; } .channel-name { @@ -362,6 +391,102 @@ button:disabled { text-overflow: ellipsis; } +.people-section { + padding: 16px; + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.people-search-input { + width: 100%; + padding: 6px 10px; + border: 1px solid #3a3f4d; + border-radius: 4px; + background: #101218; + color: #f1f1f1; + font-size: 0.85rem; + margin-bottom: 8px; +} + +.people-search-input:focus { + outline: none; + border-color: var(--primary-color); +} + +.people-list-container { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + position: relative; +} + +.people-list { + padding: 0; + position: relative; + min-height: 0; + width: 100%; +} + +.people-list-spacer { + width: 100%; + flex-shrink: 0; + height: 0; +} + +.people-item { + padding: 6px 12px; + border-radius: 4px; + margin-bottom: 2px; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + font-size: 0.85rem; +} + +.people-item:hover { + background: rgba(255, 255, 255, 0.05); +} + +.people-item.op { + color: #ff5f87; + font-weight: bold; +} + +.people-item.voiced { + color: #5af78e; +} + +.people-icon { + width: 14px; + height: 14px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.people-icon svg { + width: 100%; + height: 100%; +} + +.people-prefix { + font-size: 0.75rem; + width: 12px; + text-align: center; + flex-shrink: 0; +} + +.people-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; +} + /* Main Content Styles */ .main-content { flex: 1; @@ -528,7 +653,8 @@ button:disabled { .sidebar.collapsed .form-group, .sidebar.collapsed .button-group, .sidebar.collapsed .status-indicator, -.sidebar.collapsed .channel-name { +.sidebar.collapsed .channel-name, +.sidebar.collapsed .people-name { display: none; } @@ -537,11 +663,13 @@ button:disabled { } .sidebar.collapsed .connection-section, -.sidebar.collapsed .channel-section { +.sidebar.collapsed .channel-section, +.sidebar.collapsed .people-section { padding: 16px 8px; } -.sidebar.collapsed .channel-item { +.sidebar.collapsed .channel-item, +.sidebar.collapsed .people-item { justify-content: center; padding: 8px; } diff --git a/src/tauri.js b/src/tauri.js index a04759f..86f356b 100644 --- a/src/tauri.js +++ b/src/tauri.js @@ -128,14 +128,69 @@ sendBtn.onclick = async () => { } }; -// Listen to backend IRC messages +// Listen to backend IRC messages (async processing) listen('irc-message', event => { if (event.payload) { - /*const formattedMessage = formatIRCMessage(event.payload); - if (formattedMessage) { - addIRCMessage(formattedMessage); - }*/ - addIRCMessage(event.payload); + const message = event.payload; + + // Process message parsing asynchronously to avoid blocking + setTimeout(() => { + // Check if this is a NAMES message + if (typeof message === 'string' && message.includes('NAMES')) { + // Parse NAMES message: extract channel and users + // Format from backend: server[NAMES] channel: user1 user2... + // Match pattern: NAMES ... channel: users... + const namesMatch = message.match(/NAMES<\/span>]*>\]<\/span>\s*]*>([^<]+)<\/span>]*>:\s*<\/span>(.*)/); + if (namesMatch) { + const channel = namesMatch[1].trim(); + const usersHtml = namesMatch[2]; + + // Extract all user names from spans (they may have different styles) - async + requestAnimationFrame(() => { + const userMatches = usersHtml.matchAll(/]*>([^<]+)<\/span>/g); + const users = Array.from(userMatches, m => m[1].trim()).filter(u => u && u.length > 0); + const usersStr = users.join(' '); + + if (channel && usersStr) { + parseNamesMessage(channel, usersStr); + } + }); + } else { + // Try simpler pattern - just look for channel after NAMES + const altMatch = message.match(/NAMES.*?]*>([^<]+)<\/span>]*>:\s*<\/span>(.*)/); + if (altMatch) { + const channel = altMatch[1].trim(); + const usersHtml = altMatch[2]; + requestAnimationFrame(() => { + const userMatches = usersHtml.matchAll(/]*>([^<]+)<\/span>/g); + const users = Array.from(userMatches, m => m[1].trim()).filter(u => u && u.length > 0); + const usersStr = users.join(' '); + if (channel && usersStr) { + parseNamesMessage(channel, usersStr); + } + }); + } + } + } else if (typeof message === 'string' && message.includes('ENDOFNAMES')) { + // When ENDOFNAMES is received, the list is complete + // Extract channel to mark list as complete (optional - for future use) + const endMatch = message.match(/ENDOFNAMES.*?]*>([^<]+)<\/span>/); + if (endMatch) { + const channel = endMatch[1].trim(); + // List is complete, ensure it's displayed (async) + requestAnimationFrame(() => { + if (uiState.activeTab === channel) { + updatePeopleList(channel); + } + }); + } + } + }, 0); + + // Display message asynchronously + requestAnimationFrame(() => { + addIRCMessage(message); + }); } });