// ==UserScript==
// @name KG_Better_Chatlogs
// @namespace https://klavogonki.ru
// @version 1.0.7
// @description Restyle chatlogs: remove brackets, convert font to span.username, remove unwanted timezone elements, group messages into .message-item wrapped in .messages-wrapper, wrap links, wrap time/username in an .info container, and add smooth hover transitions with responsive design. Now with SVG navigation icons and tablet optimization.
// @author Patcher
// @match *://klavogonki.ru/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=klavogonki.ru
// @grant none
// ==/UserScript==
(function () {
function run() {
const BASE_URL = location.protocol + "//klavogonki.ru",
CHATLOGS_URL = BASE_URL + "/chatlogs",
IS_HOME = location.pathname === "/" || location.pathname === "",
IS_CHAT = location.href.includes("/chatlogs/");
if (IS_CHAT) {
// Apply the background color immediately to prevent white flash
document.documentElement.style.setProperty('background-color', '#1e1e1e', 'important');
document.body.style.setProperty('background-color', '#1e1e1e', 'important');
}
const setStyle = (el, styles) =>
Object.entries(styles).forEach(([prop, val]) => el.style.setProperty(prop, val, 'important'));
const getCurrentChatlogsUrl = () => {
const now = new Date(), pad = n => String(n).padStart(2, '0');
return `${CHATLOGS_URL}/${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}.html`;
};
// Helper: Check if a URL is encoded
const isEncodedURL = url => {
try {
return decodeURIComponent(url) !== url;
} catch (e) {
return false;
}
};
// Helper: Decode URL safely
const decodeURL = url => {
try {
return decodeURIComponent(url);
} catch (e) {
return url;
}
};
// Helper: Shorten URL display text similar to linkify
const shortenUrl = (url, maxUrlLength = 50) => {
if (url.length <= maxUrlLength) {
return url;
}
try {
const urlObj = new URL(url);
const domain = urlObj.hostname;
const protocol = urlObj.protocol;
const path = urlObj.pathname;
// Calculate available characters after protocol and domain with some buffer for "://"
const remainingChars = maxUrlLength - domain.length - protocol.length - 2;
let displayUrl;
if (remainingChars > 10) {
// Show beginning and end of path with ellipsis in the middle
const partLength = Math.floor(remainingChars / 3);
const firstPart = path.slice(0, partLength);
const lastPart = path.slice(-partLength);
displayUrl = `${protocol}//${domain}${firstPart}...${lastPart}`;
} else {
// If not enough room, just show domain with ellipsis
displayUrl = `${protocol}//${domain}/...`;
}
// Append indicator if there are query parameters
if (urlObj.search) {
displayUrl += '?...';
}
return displayUrl;
} catch (e) {
return url;
}
};
const parseMessageText = text => {
let i = 0, urls = [];
// Extract URLs and replace them with placeholders
text = text.replace(
/(\b(https?|ftp):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/ig,
m => {
urls.push(m);
return `___URL${i++}___`;
}
);
// Replace emoticons wrapped in colons with an <img> tag.
// Use BASE_URL to build the correct image URL: BASE_URL + "/img/smilies/" + name + ".gif"
text = text.replace(/:(\w+):/g, (_, name) =>
`<img src="${BASE_URL}/img/smilies/${name}.gif" alt="${name}" class="emoji">`
)
// Retain proper emoji presentation by wrapping each emoji in a span with the "emoji-adjuster" class.
.replace(/(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)/gu, '<span class="emoji-adjuster">$&</span>');
// Convert each URL placeholder back into a clickable anchor tag,
// using the decoded URL and a shortened display text for improved readability.
urls.forEach((url, idx) => {
let finalUrl = url;
if (isEncodedURL(url)) {
finalUrl = decodeURL(url);
}
const displayUrl = shortenUrl(finalUrl, 50);
const anchor = `<a href="${finalUrl}" target="_blank" rel="noopener noreferrer">${displayUrl}</a>`;
text = text.replace(`___URL${idx}___`, anchor);
});
return text;
};
const colorizeNicknames = () => {
setStyle(document.body, {
'font-size': '1em',
'font-family': 'Montserrat, "Noto Color Emoji", sans-serif'
});
const nicknameColors = {};
document.querySelectorAll("font.mn").forEach(el => {
const username = el.textContent.replace(/[<>]/g, '').trim(),
span = document.createElement('span');
span.className = 'username';
span.textContent = username;
if (el.getAttribute('style')) span.setAttribute('style', el.getAttribute('style'));
el.parentNode.replaceChild(span, el);
});
document.querySelectorAll("span.username").forEach(el => {
if (!el.style.color) {
const nick = el.textContent;
if (!nicknameColors[nick]) {
const hue = Math.floor(Math.random() * 360),
sat = Math.floor(Math.random() * 20) + 80;
nicknameColors[nick] = `hsl(${hue}, ${sat}%, 70%)`;
}
el.style.setProperty('color', nicknameColors[nick], 'important');
}
});
};
const createSVG = (icon, data) =>
`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-${icon}">${data}</svg>`;
const beautifyNavigation = () => {
// Look for the nav wrapper (with class "w3c")
let navWrapper = document.querySelector('.logdate .w3c') || document.querySelector('a.nav')?.parentNode;
if (navWrapper) {
// Change class from "w3c" to "navigation"
navWrapper.classList.remove('w3c');
navWrapper.classList.add('navigation');
// Default desktop styles - will be overridden by media query for tablets
setStyle(navWrapper, {
'position': 'fixed',
'height': 'auto',
'width': 'auto',
'right': '0.5em',
'bottom': '50vh',
'transform': 'translateY(50%)',
'display': 'flex',
'flex-direction': 'column',
'gap': '0.5em',
'z-index': '10000'
});
// Handle tablet responsive layout with JavaScript
const checkTabletWidth = () => {
if (window.innerWidth <= 1024) { // Tablet breakpoint
setStyle(navWrapper, {
'width': '100%',
'right': '0',
'bottom': '0.5em',
'transform': 'none',
'flex-direction': 'row',
'justify-content': 'center',
'gap': '0.5em'
});
} else {
setStyle(navWrapper, {
'width': 'auto',
'right': '0.5em',
'bottom': '50vh',
'transform': 'translateY(50%)',
'flex-direction': 'column',
'justify-content': 'flex-start',
'gap': '0.5em'
});
}
};
// Initial check and listen for resize events
checkTabletWidth();
window.addEventListener('resize', checkTabletWidth);
const navButtons = navWrapper.querySelectorAll('a.nav');
navButtons.forEach(btn => {
setStyle(btn, {
'display': 'flex',
'justify-content': 'center',
'align-items': 'center',
'height': '40px',
'width': '40px',
'background-color': '#808080',
'color': '#1b1b1b',
'transition': 'background-color 0.15s'
});
// Uniform border-radius for all buttons
btn.style.setProperty('border-radius', '4px', 'important');
btn.addEventListener('mouseenter', () => btn.style.setProperty('background-color', '#a9a9a9', 'important'));
btn.addEventListener('mouseleave', () => btn.style.setProperty('background-color', '#808080', 'important'));
// Set appropriate SVG icon and class based on button text or href
if (btn.getAttribute('href') === "./") {
// Home button - fix to go to BASE_URL instead of chatlogs
btn.href = BASE_URL;
btn.innerHTML = createSVG('home', '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline>');
btn.classList.add('home');
} else if (btn.textContent === "<") {
// Backward button
btn.innerHTML = createSVG('arrow-left', '<line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline>');
btn.classList.add('backward');
} else if (btn.textContent === ">") {
// Forward button
btn.innerHTML = createSVG('arrow-right', '<line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline>');
btn.classList.add('forward');
}
});
} else {
// If no nav wrapper exists, create a dedicated home button.
let homeButton = document.querySelector('a.home-btn');
if (!homeButton) {
homeButton = document.createElement('a');
homeButton.href = BASE_URL; // Fix to go to BASE_URL instead of chatlogs
homeButton.className = 'home-btn home';
document.body.appendChild(homeButton);
}
// Default position for desktop
setStyle(homeButton, {
'position': 'fixed',
'right': '30px',
'bottom': '50vh',
'transform': 'translateY(-90px)',
'height': '40px',
'width': '40px',
'display': 'flex',
'justify-content': 'center',
'align-items': 'center',
'border-radius': '4px',
'color': '#1b1b1b',
'background-color': '#808080',
'transition': 'background-color 0.15s',
'z-index': '10000'
});
// Handle tablet responsive layout for standalone home button
const checkTabletWidthForHome = () => {
if (window.innerWidth <= 1024) { // Tablet breakpoint
setStyle(homeButton, {
'right': 'auto',
'bottom': '0.5em',
'left': '50%',
'transform': 'translateX(-50%)'
});
} else {
setStyle(homeButton, {
'right': '30px',
'bottom': '50vh',
'left': 'auto',
'transform': 'translateY(-90px)'
});
}
};
// Initial check and listen for resize events
checkTabletWidthForHome();
window.addEventListener('resize', checkTabletWidthForHome);
homeButton.addEventListener('mouseenter', () => homeButton.style.setProperty('background-color', '#a9a9a9', 'important'));
homeButton.addEventListener('mouseleave', () => homeButton.style.setProperty('background-color', '#808080', 'important'));
homeButton.innerHTML = createSVG('home', '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline>');
}
};
const addChatlogsLink = () => {
const menu = document.querySelector(".right .menu");
if (menu) {
// Check if the link already exists
const existingLink = Array.from(menu.querySelectorAll('a')).find(a =>
a.textContent === "Chatlogs" || a.href.includes("/chatlogs/"));
if (!existingLink) {
const a = document.createElement("a");
a.href = getCurrentChatlogsUrl();
a.textContent = "Chatlogs";
menu.appendChild(a);
console.log("Added Chatlogs link to menu");
}
} else {
console.log("Menu not found");
}
};
const preventFutureNavigation = () => {
const match = location.href.match(/\d{4}-\d{2}-\d{2}/);
if (match) {
const chatDate = new Date(match[0]),
today = new Date();
chatDate.setHours(0, 0, 0, 0);
today.setHours(0, 0, 0, 0);
if (chatDate > today) location.href = getCurrentChatlogsUrl();
}
};
const removeElements = () => {
document.querySelector('.legend')?.remove();
document.querySelectorAll('.rc').forEach(el => {
const title = el.querySelector('.rct');
if (title && (title.textContent.includes('Room Configuration') || title.textContent.includes('Room Occupants')))
el.remove();
});
document.querySelector('.roomtitle')?.remove();
const dateElem = document.querySelector('.logdate');
if (dateElem) {
setStyle(dateElem, { 'border': 'none', 'margin-top': '0' });
dateElem.childNodes.forEach(n => { if (n.nodeType === Node.TEXT_NODE) n.nodeValue = ''; });
}
document.querySelectorAll('br').forEach(br => br.remove());
document.querySelectorAll('.ts').forEach(el => { if (/GMT/i.test(el.textContent)) el.remove(); });
};
const restructureMessages = () => {
const timeMarkers = Array.from(document.querySelectorAll('a.ts')).filter(el => !/GMT/i.test(el.textContent));
if (!timeMarkers.length) return;
const container = timeMarkers[0].parentNode;
// Detach navWrapper (if any) so it isn't cleared.
const navWrapper = container.querySelector('.w3c') || container.querySelector('.navigation');
if (navWrapper) navWrapper.remove();
const messagesWrapper = document.createElement('div');
messagesWrapper.className = 'messages-wrapper';
timeMarkers.forEach((current, i) => {
const next = timeMarkers[i + 1] || null,
messageItem = document.createElement('div');
messageItem.className = 'message-item';
// Create .info container for time and username.
const infoDiv = document.createElement('div');
infoDiv.className = 'info';
const timeText = current.textContent.replace(/[\[\]]/g, '').trim(),
newTime = document.createElement('time');
newTime.className = 'time';
newTime.textContent = timeText;
infoDiv.appendChild(newTime);
let usernameEl = null, messageParts = [];
for (let node = current.nextSibling; node && node !== next; node = node.nextSibling) {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.classList.contains('username')) {
usernameEl = node.cloneNode(true);
} else {
const txt = node.textContent.trim();
if (txt) messageParts.push(txt);
}
} else if (node.nodeType === Node.TEXT_NODE) {
const txt = node.textContent.trim();
if (txt) messageParts.push(txt);
}
}
if (usernameEl) infoDiv.appendChild(usernameEl);
messageItem.appendChild(infoDiv);
const message = document.createElement('p');
message.className = 'message';
message.innerHTML = parseMessageText(messageParts.join(' '));
messageItem.appendChild(message);
messagesWrapper.appendChild(messageItem);
});
// Clear container and reinsert detached navWrapper (if any)
container.innerHTML = '';
if (navWrapper) container.appendChild(navWrapper);
container.appendChild(messagesWrapper);
};
const injectCustomStyles = () => {
// Check and insert meta viewport if not already present.
if (!document.querySelector('meta[name="viewport"]')) {
document.head.insertAdjacentHTML(
'beforeend',
'<meta name="viewport" content="width=device-width, initial-scale=1.0">'
);
}
// Check if our custom style is already injected.
if (!document.getElementById('custom-chatlogs-styles')) {
const style = document.createElement('style');
style.id = 'custom-chatlogs-styles';
style.textContent = `
@import url('https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100..900;1,100..900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&display=swap');
.emoji-adjuster { font-size: 22px !important; }
.time {
color: #666 !important;
transition: color 0.2s !important;
font-size: 0.8em !important;
font-variant-numeric: tabular-nums !important;
}
.info {
display: flex !important;
align-items: center !important;
gap: 10px !important;
margin-right: 10px !important;
}
.message {
color: #deb887 !important;
margin: 0 !important;
}
a { color: #82B32A !important; }
a:hover { color: #95cc30 !important; }
.message-item {
margin-bottom: 10px !important;
display: flex !important;
flex-direction: row !important;
}
.messages-wrapper {
display: flex !important;
flex-direction: column !important;
}
@media (max-width: 768px) {
.message-item { flex-direction: column !important; }
}
@media (max-width: 1024px) {
body {
padding-bottom: 50px !important;
}
}
`;
document.head.appendChild(style);
}
};
const enhanceChatlogs = () => {
if (!IS_CHAT) return;
colorizeNicknames();
beautifyNavigation();
removeElements();
restructureMessages();
preventFutureNavigation();
};
const init = () => {
injectCustomStyles();
if (IS_CHAT) enhanceChatlogs();
addChatlogsLink();
};
init();
}
run();
})();