ui changes
This commit is contained in:
@@ -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<Arc<Mutex<Client>>>,
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
mod connection_manager;
|
||||
mod command; // now it exists
|
||||
mod message_formatter;
|
||||
|
||||
use connection_manager::ConnectionManager;
|
||||
use command::{
|
||||
|
||||
197
src-tauri/src/message_formatter.rs
Normal file
197
src-tauri/src/message_formatter.rs
Normal file
@@ -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!("<span style='color: #5ddfff;'>{}</span>", escape_html(server))
|
||||
},
|
||||
Some(Prefix::Nickname(nick, user, host)) => {
|
||||
if !host.is_empty() {
|
||||
format!("<span style='color: #5af78e;'>{}</span><span style='color: #666;'>@{}</span>",
|
||||
escape_html(nick), escape_html(host))
|
||||
} else if !user.is_empty() {
|
||||
format!("<span style='color: #5af78e;'>{}</span><span style='color: #666;'>@{}</span>",
|
||||
escape_html(nick), escape_html(user))
|
||||
} else {
|
||||
format!("<span style='color: #5af78e;'>{}</span>", escape_html(nick))
|
||||
}
|
||||
},
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
// Format based on command type
|
||||
match &message.command {
|
||||
Command::NOTICE(target, text) => {
|
||||
format!("<span style='color: #666;'><span style='color: #ffd700;'>[NOTICE]</span> </span>{}<span style='color: #666;'> → </span><span style='color: #666;'>{}</span><span style='color: #666;'>: </span><span style='color: #ffd700;'>{}</span>",
|
||||
prefix_str,
|
||||
escape_html(target),
|
||||
escape_html(text))
|
||||
},
|
||||
Command::PRIVMSG(target, text) => {
|
||||
format!("{}<span style='color: #666;'> → </span><span style='color: #666;'>{}</span><span style='color: #666;'>: </span><span style='color: #fff;'>{}</span>",
|
||||
prefix_str,
|
||||
escape_html(target),
|
||||
escape_html(text))
|
||||
},
|
||||
Command::JOIN(channel, keys, _) => {
|
||||
let keys_str = if let Some(k) = keys {
|
||||
format!(" <span style='color: #666;'>(key: {})</span>", escape_html(k))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!("{}<span style='color: #5af78e;'> joined </span><span style='color: #5ddfff;'>{}</span>{}",
|
||||
prefix_str,
|
||||
escape_html(channel),
|
||||
keys_str)
|
||||
},
|
||||
Command::PART(channel, msg) => {
|
||||
let msg_str = if let Some(m) = msg {
|
||||
format!(" <span style='color: #666;'>({})</span>", escape_html(m))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!("{}<span style='color: #ff5f87;'> left </span><span style='color: #5ddfff;'>{}</span>{}",
|
||||
prefix_str,
|
||||
escape_html(channel),
|
||||
msg_str)
|
||||
},
|
||||
Command::QUIT(msg) => {
|
||||
let msg_str = if let Some(m) = msg {
|
||||
format!(" <span style='color: #666;'>({})</span>", escape_html(m))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!("{}<span style='color: #ff5f87;'> quit</span>{}",
|
||||
prefix_str,
|
||||
msg_str)
|
||||
},
|
||||
Command::NICK(new_nick) => {
|
||||
format!("{}<span style='color: #5af78e;'> is now known as </span><span style='color: #5af78e;'>{}</span>",
|
||||
prefix_str,
|
||||
escape_html(new_nick))
|
||||
},
|
||||
Command::TOPIC(channel, topic) => {
|
||||
if let Some(t) = topic {
|
||||
format!("{}<span style='color: #5ddfff;'>Topic for </span><span style='color: #5ddfff;'>{}</span><span style='color: #666;'>: </span><span style='color: #ffd700;'>{}</span>",
|
||||
prefix_str,
|
||||
escape_html(channel),
|
||||
escape_html(t))
|
||||
} else {
|
||||
format!("{}<span style='color: #5ddfff;'>Topic for </span><span style='color: #5ddfff;'>{}</span><span style='color: #666;'> cleared</span>",
|
||||
prefix_str,
|
||||
escape_html(channel))
|
||||
}
|
||||
},
|
||||
Command::KICK(channel, user, msg) => {
|
||||
let msg_str = if let Some(m) = msg {
|
||||
format!(" <span style='color: #666;'>({})</span>", escape_html(m))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!("{}<span style='color: #ff5f87;'> kicked </span><span style='color: #ff5f87;'>{}</span><span style='color: #666;'> from </span><span style='color: #5ddfff;'>{}</span>{}",
|
||||
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<String> = users.iter()
|
||||
.map(|u| {
|
||||
// Check for channel prefixes (@ for ops, + for voiced)
|
||||
if u.starts_with('@') {
|
||||
format!("<span style='color: #ff5f87; font-weight: bold;'>{}</span>", escape_html(u))
|
||||
} else if u.starts_with('+') {
|
||||
format!("<span style='color: #5af78e;'>{}</span>", escape_html(u))
|
||||
} else {
|
||||
format!("<span style='color: #fff;'>{}</span>", escape_html(u))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
return format!("{}<span style='color: #666;'>[</span><span style='color: #5ddfff;'>NAMES</span><span style='color: #666;'>] </span><span style='color: #5ddfff;'>{}</span><span style='color: #666;'>: </span>{}",
|
||||
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!("{}<span style='color: #666;'>[</span><span style='color: #5ddfff;'>ENDOFNAMES</span><span style='color: #666;'>] </span><span style='color: #5ddfff;'>{}</span><span style='color: #666;'>: End of /NAMES list.</span>",
|
||||
prefix_str,
|
||||
escape_html(channel));
|
||||
}
|
||||
}
|
||||
|
||||
let code_str = format!("{:?}", code);
|
||||
let params_str = params.iter()
|
||||
.map(|p| format!("<span style='color: #fff;'>{}</span>", escape_html(p)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
format!("{}<span style='color: #666;'>[</span><span style='color: #5ddfff;'>{}</span><span style='color: #666;'>]</span> {}",
|
||||
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!("<span style='color: #666;'><span style='color: #5af78e;'>[PING]</span> </span>{}",
|
||||
escape_html(&servers))
|
||||
},
|
||||
Command::PONG(server1, server2) => {
|
||||
let servers = if let Some(s2) = server2 {
|
||||
format!("{}, {}", server1, s2)
|
||||
} else {
|
||||
server1.clone()
|
||||
};
|
||||
format!("<span style='color: #666;'><span style='color: #5af78e;'>[PONG]</span> </span>{}",
|
||||
escape_html(&servers))
|
||||
},
|
||||
Command::INVITE(user, channel) => {
|
||||
format!("{}<span style='color: #ff87d7;'> invited </span><span style='color: #5af78e;'>{}</span><span style='color: #666;'> to </span><span style='color: #5ddfff;'>{}</span>",
|
||||
prefix_str,
|
||||
escape_html(user),
|
||||
escape_html(channel))
|
||||
},
|
||||
Command::Raw(code, params) => {
|
||||
let params_str = params.iter()
|
||||
.map(|p| format!("<span style='color: #666;'>{}</span>", escape_html(p)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
format!("{}<span style='color: #666;'>[RAW {}]</span> {}",
|
||||
prefix_str,
|
||||
code,
|
||||
params_str)
|
||||
},
|
||||
_ => {
|
||||
// Fallback for unhandled commands
|
||||
format!("{}<span style='color: #666;'>[{:?}]</span>",
|
||||
prefix_str,
|
||||
message.command)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,8 @@
|
||||
"windows": [
|
||||
{
|
||||
"title": "ircd",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
"width": 860,
|
||||
"height": 800
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
742
src/UI.js
742
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<channel, Set<user>>
|
||||
channelMessages: new Map(), // Map<channel, Array<{type, sender, content, timestamp, html}>>
|
||||
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 = '<path d="M15 18L9 12L15 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>';
|
||||
} else {
|
||||
icon.innerHTML = '<path d="M9 18L15 12L9 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>';
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Add to channel list
|
||||
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 = `
|
||||
<div class="channel-icon">#</div>
|
||||
<div class="channel-name">${name}</div>
|
||||
<div class="channel-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 9C5 5.13401 8.13401 2 12 2C15.866 2 19 5.13401 19 9C19 10.933 18.2165 12.683 16.8569 14.0002C16.8569 14.0002 16.5 14.5 15.5 15.5C14.5 16.5 13 18 13 18L12 19L11 18C11 18 9.5 16.5 8.5 15.5C7.5 14.5 7.14314 14.0002 7.14314 14.0002C5.78354 12.683 5 10.933 5 9Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9 9H15M9 12H15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="channel-name">${escapeHtml(normalizedName)}</div>
|
||||
`;
|
||||
channelItem.addEventListener('click', () => setActiveTab(name));
|
||||
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 = `
|
||||
<span>${name}</span>
|
||||
<span>${normalizedName}</span>
|
||||
<span class="tab-close">×</span>
|
||||
`;
|
||||
|
||||
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,36 +390,122 @@ 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
|
||||
uiState.activeTab = normalizedName;
|
||||
|
||||
// Update tabs (async)
|
||||
requestAnimationFrame(() => {
|
||||
const tabs = tabBar.querySelectorAll('.tab');
|
||||
tabs.forEach(tab => {
|
||||
tab.classList.toggle('active', tab.dataset.tab === name);
|
||||
// Compare with both normalized and original name
|
||||
tab.classList.toggle('active', tab.dataset.tab === normalizedName || tab.dataset.tab === name);
|
||||
});
|
||||
});
|
||||
|
||||
// Update channels
|
||||
// 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 === name);
|
||||
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}!`);
|
||||
// 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Message handling
|
||||
@@ -350,6 +542,22 @@ function handleCommand(command) {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Store message in RAM - ALWAYS store
|
||||
if (!uiState.channelMessages.has(channelKey)) {
|
||||
uiState.channelMessages.set(channelKey, []);
|
||||
}
|
||||
|
||||
// Use requestAnimationFrame for async DOM updates
|
||||
requestAnimationFrame(() => {
|
||||
const messageLine = document.createElement('div');
|
||||
messageLine.className = 'message-line';
|
||||
|
||||
@@ -365,26 +573,261 @@ function addMessage(sender, content, isUser = false) {
|
||||
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 channel = uiState.activeTab;
|
||||
if (!channel || channel === 'welcome') return;
|
||||
|
||||
// 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) {
|
||||
// Parse the message to determine which channel it belongs to
|
||||
let targetChannel = null;
|
||||
let joinMatch = null;
|
||||
let partMatch = null;
|
||||
|
||||
// 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: <sender> → <channel>: <message>
|
||||
const privmsgMatch = content.match(/→\s*<span[^>]*>([^<]+)<\/span><span[^>]*>:\s*<\/span>/);
|
||||
if (privmsgMatch) {
|
||||
targetChannel = privmsgMatch[1].trim();
|
||||
}
|
||||
|
||||
// NOTICE format: [NOTICE] <sender> → <target>: <message>
|
||||
// Only if target is a channel (starts with #)
|
||||
const noticeMatch = content.match(/\[NOTICE\].*?→\s*<span[^>]*>([^<]+)<\/span><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: <sender> joined <channel>
|
||||
joinMatch = content.match(/joined\s*<span[^>]*>([^<]+)<\/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: <sender> left <channel>
|
||||
partMatch = content.match(/left\s*<span[^>]*>([^<]+)<\/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: <sender> 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: <sender> is now known as <newnick>
|
||||
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] <channel>:
|
||||
const namesMatch = content.match(/\[NAMES\]\s*<\/span>\s*<span[^>]*>([^<]+)<\/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.textContent = content;
|
||||
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 = `
|
||||
<div class="people-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 21V19C20 17.9391 19.5786 16.9217 18.8284 16.1716C18.0783 15.4214 17.0609 15 16 15H8C6.93913 15 5.92172 15.4214 5.17157 16.1716C4.42143 16.9217 4 17.9391 4 19V21" stroke="${iconColor}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="12" cy="7" r="4" stroke="${iconColor}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
${prefix ? `<span class="people-prefix">${prefix}</span>` : '<span class="people-prefix"></span>'}
|
||||
<span class="people-name">${escapeHtml(displayName)}</span>
|
||||
`;
|
||||
|
||||
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);
|
||||
@@ -23,7 +23,14 @@
|
||||
</div>
|
||||
|
||||
<div class="connection-section">
|
||||
<h3 class="section-title">Connection</h3>
|
||||
<h3 class="section-title">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="section-icon">
|
||||
<path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2 17L12 22L22 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2 12L12 17L22 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Connection
|
||||
</h3>
|
||||
<div class="form-group">
|
||||
<label for="server">Server</label>
|
||||
<input id="server" type="text" value="irc.libera.chat" placeholder="irc.example.net">
|
||||
@@ -47,7 +54,12 @@
|
||||
</div>
|
||||
|
||||
<div class="channel-section">
|
||||
<h3 class="section-title">Channels</h3>
|
||||
<h3 class="section-title">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="section-icon">
|
||||
<path d="M21 15C21 15.5304 20.7893 16.0391 20.4142 16.4142C20.0391 16.7893 19.5304 17 19 17H7L3 21V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V15Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Channels
|
||||
</h3>
|
||||
<div class="form-group">
|
||||
<label for="newChannel">Join Channel</label>
|
||||
<input id="newChannel" type="text" placeholder="#channel">
|
||||
@@ -83,6 +95,40 @@
|
||||
<button id="sendBtn">Send</button>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<!-- People Sidebar -->
|
||||
<aside class="sidebar sidebar-right" id="peopleSidebar">
|
||||
<div class="sidebar-resize-handle sidebar-resize-handle-left" id="peopleSidebarResizeHandle"></div>
|
||||
<div class="sidebar-header">
|
||||
<h1>People</h1>
|
||||
<button class="toggle-sidebar" id="togglePeopleSidebar">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 18L15 12L9 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="people-section">
|
||||
<h3 class="section-title">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="section-icon">
|
||||
<path d="M17 21V19C17 17.9391 16.5786 16.9217 15.8284 16.1716C15.0783 15.4214 14.0609 15 13 15H5C3.93913 15 2.92172 15.4214 2.17157 16.1716C1.42143 16.9217 1 17.9391 1 19V21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="9" cy="7" r="4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M23 21V19C22.9993 18.1137 22.7044 17.2528 22.1614 16.5523C21.6184 15.8519 20.8581 15.3516 20 15.13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M16 3.13C16.8604 3.35031 17.623 3.85071 18.1676 4.55232C18.7122 5.25392 19.0078 6.11683 19.0078 7.005C19.0078 7.89318 18.7122 8.75608 18.1676 9.45769C17.623 10.1593 16.8604 10.6597 16 10.88" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Channel Users
|
||||
</h3>
|
||||
<div class="form-group">
|
||||
<input id="peopleSearch" type="text" placeholder="Search users..." class="people-search-input">
|
||||
</div>
|
||||
<div class="people-list-container" id="peopleListContainer">
|
||||
<div class="people-list-spacer" id="peopleListSpacer"></div>
|
||||
<div class="people-list" id="peopleList">
|
||||
<!-- People will be added here dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
67
src/tauri.js
67
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: <span>server</span><span>[</span><span>NAMES</span><span>]</span> <span>channel</span><span>: </span><span>user1</span> <span>user2</span>...
|
||||
// Match pattern: NAMES</span> ... <span>channel</span><span>: </span>users...
|
||||
const namesMatch = message.match(/NAMES<\/span><span[^>]*>\]<\/span>\s*<span[^>]*>([^<]+)<\/span><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[^>]*>([^<]+)<\/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[^>]*>([^<]+)<\/span><span[^>]*>:\s*<\/span>(.*)/);
|
||||
if (altMatch) {
|
||||
const channel = altMatch[1].trim();
|
||||
const usersHtml = altMatch[2];
|
||||
requestAnimationFrame(() => {
|
||||
const userMatches = usersHtml.matchAll(/<span[^>]*>([^<]+)<\/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[^>]*>([^<]+)<\/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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user