From 33e5a4e07f97561e1b2088ef6adfd8324293b57d Mon Sep 17 00:00:00 2001 From: rattatwinko Date: Wed, 19 Nov 2025 13:41:19 +0100 Subject: [PATCH] new frontend --- src/UI.js | 618 ++++++++++-------------------------------- src/index.html | 4 +- src/mobile.js | 613 +++++++++++++++++++++++++++++++++++++++++ src/styles/mobile.css | 223 +++++++++++++++ src/styles/styles.css | 581 +++++++++++++++++++++++++++++++++++---- 5 files changed, 1505 insertions(+), 534 deletions(-) create mode 100644 src/mobile.js create mode 100644 src/styles/mobile.css diff --git a/src/UI.js b/src/UI.js index e817e0c..b231cf4 100644 --- a/src/UI.js +++ b/src/UI.js @@ -31,7 +31,6 @@ const uiState = { userColors: new Map(), currentUser: 'IRCDUser', channelUsers: new Map(), // Map> - channelMessages: new Map(), // Map> peopleSearchQuery: '', // Current search query for people list virtualScrollState: { startIndex: 0, @@ -280,30 +279,23 @@ function addChannel(name) { if (!uiState.channels.includes(normalizedName)) { uiState.channels.push(normalizedName); - // 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 to channel list + const channelItem = document.createElement('div'); + channelItem.className = 'channel-item'; + channelItem.innerHTML = ` +
+ + + + +
+
${escapeHtml(normalizedName)}
+ `; + channelItem.addEventListener('click', () => setActiveTab(normalizedName)); + channelList.appendChild(channelItem); - // Add tab (async) - requestAnimationFrame(() => addTab(normalizedName)); - - // Initialize message storage for this channel - if (!uiState.channelMessages.has(normalizedName)) { - uiState.channelMessages.set(normalizedName, []); - } + // Add tab + addTab(normalizedName); // Auto-join the channel when connected if (uiState.connected) { @@ -330,9 +322,6 @@ 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); @@ -398,114 +387,51 @@ function setActiveTab(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 tabs + 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 (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 channels + 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 people list for the active channel (async) + // Update people list for the active channel updatePeopleList(normalizedName); - // Update messages (async) - requestAnimationFrame(() => { - updateMessagesForTab(normalizedName); - }); + // Update messages + updateMessagesForTab(normalizedName); } function updateMessagesForTab(name) { // Clear messages messagesContainer.innerHTML = ''; - // Ensure channel name format is correct - let channelKey = name; - if (name !== 'welcome' && !name.startsWith('#')) { - channelKey = '#' + name; + // Add welcome messages + if (name === 'welcome') { + const welcomeMsg = document.createElement('div'); + welcomeMsg.className = 'system-message'; + welcomeMsg.textContent = 'IRCD - Welcome! SystemTime: ' + new Date().toLocaleString(); + messagesContainer.appendChild(welcomeMsg); + } else { + // For channels, just show a welcome message + const welcomeMsg = document.createElement('div'); + welcomeMsg.className = 'system-message'; + welcomeMsg.textContent = `Welcome to ${name}!`; + messagesContainer.appendChild(welcomeMsg); + + const infoMsg = document.createElement('div'); + infoMsg.className = 'system-message'; + infoMsg.textContent = 'This is the beginning of the channel.'; + messagesContainer.appendChild(infoMsg); } - // 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 { - // 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.'); - } - } + scrollToBottom(); } // Message handling @@ -545,289 +471,44 @@ function addMessage(sender, content, isUser = false) { const channel = uiState.activeTab; if (!channel || channel === 'welcome') return; - // Normalize channel name - let channelKey = channel; - if (!channelKey.startsWith('#')) { - channelKey = '#' + channelKey; - } + const messageLine = document.createElement('div'); + messageLine.className = 'message-line'; - // Store message in RAM - ALWAYS store - if (!uiState.channelMessages.has(channelKey)) { - uiState.channelMessages.set(channelKey, []); - } + const senderElement = document.createElement('div'); + senderElement.className = `message-sender ${isUser ? 'you' : ''}`; + senderElement.textContent = isUser ? `${sender} (You):` : `${sender}:`; + senderElement.style.color = isUser ? '' : getUserColor(sender); - // 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(); - }); + const contentElement = document.createElement('div'); + contentElement.className = 'message-content'; + contentElement.textContent = content; + + messageLine.appendChild(senderElement); + messageLine.appendChild(contentElement); + + messagesContainer.appendChild(messageLine); + scrollToBottom(); } function addSystemMessage(content) { const channel = uiState.activeTab; if (!channel || channel === 'welcome') return; - // Normalize channel name - let channelKey = channel; - if (!channelKey.startsWith('#')) { - channelKey = '#' + channelKey; - } + const systemMessage = document.createElement('div'); + systemMessage.className = 'system-message'; + systemMessage.textContent = content; - // 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(); - }); + messagesContainer.appendChild(systemMessage); + scrollToBottom(); } function addIRCMessage(content) { - // Parse the message to determine which channel it belongs to - let targetChannel = null; - let joinMatch = null; - let partMatch = null; + const ircMessage = document.createElement('div'); + ircMessage.className = 'irc-message'; + ircMessage.innerHTML = content; - // 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(); - }); + messagesContainer.appendChild(ircMessage); + scrollToBottom(); } function scrollToBottom() { @@ -959,42 +640,40 @@ function updatePeopleList(channel) { } // 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' }); + 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); }); - - // 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); + } + + // 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(); } function renderVisibleUsers() { @@ -1039,20 +718,18 @@ function renderVisibleUsers() { } // 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); - }); + 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) { @@ -1098,49 +775,42 @@ function handlePeopleSearch(e) { } function handlePeopleListScroll() { - requestAnimationFrame(() => { - renderVisibleUsers(); - }); + renderVisibleUsers(); } function updateVirtualScrollContainerHeight() { - requestAnimationFrame(() => { - if (peopleListContainer) { - uiState.virtualScrollState.containerHeight = peopleListContainer.clientHeight; - renderVisibleUsers(); - } - }); + 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()); + // 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); } - - 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); + }); + + // Update the people list if this is the active channel + // This will use virtual scrolling to only render visible users + if (uiState.activeTab === channel) { + updatePeopleList(channel); + } } function clearChannelUsers(channel) { diff --git a/src/index.html b/src/index.html index b11a917..e0538a9 100644 --- a/src/index.html +++ b/src/index.html @@ -7,6 +7,7 @@ +
@@ -14,7 +15,7 @@