// ==UserScript==
// @name Triangulet Chat Integration
// @namespace http://tampermonkey.net/
// @version 1.6
// @description Adds a chat feature to Triangulet
// @author fsscooter
// @match *://tri.pengpowers.xyz/*
// @match *://coplic.com/*
// @icon https://tri.pengpowers.xyz/media/misc/favicon.png
// @grant none
// @license none
// ==/UserScript==
(function() {
'use strict';
function addChatTab() {
const sidebar = document.querySelector('.styles__sidebar___1XqWi-camelCase');
if (!sidebar || sidebar.dataset.chatTabAdded) return;
const existingTab = document.querySelector('.styles__pageButton___1wFuu-camelCase');
if (!existingTab) return;
const newTab = existingTab.cloneNode(true);
newTab.href = "/stats?chat=true";
newTab.querySelector('.styles__pageIcon___3OSy9-camelCase').className =
"styles__pageIcon___3OSy9-camelCase fas fa-comments";
newTab.querySelector('.styles__pageText___1eo7q-camelCase').textContent = "Chat";
const bottomRow = document.querySelector('.styles__bottomRow___3OozA-camelCase');
if (bottomRow) {
sidebar.insertBefore(newTab, bottomRow);
sidebar.dataset.chatTabAdded = "true";
}
}
if (window.location.href.includes("/stats?chat=true")) {
transformToChat();
}
addChatTab();
const observer = new MutationObserver(addChatTab);
observer.observe(document.body, {
childList: true,
subtree: true
});
function transformToChat() {
const profileBody = document.querySelector('.arts__profileBody___eNPbH-camelCase');
if (profileBody) profileBody.style.display = "none";
const topRightRow = document.querySelector('.styles__topRightRow___dQvxc-camelCase');
if (topRightRow) {
topRightRow.insertAdjacentHTML('afterbegin', `
<div class="styles__profileContainer___CSuIE-camelCase" role="button" tabindex="0">
<div class="styles__profileRow___cJa4E-camelCase">
<div style="position: relative" class="styles__blookContainer___36LK2-camelCase styles__profileBlook___37mfP-camelCase">
<img src="https://i.ibb.co/r2gYyjdJ/output-onlinepngtools-3.png" id="status" draggable="false" class="styles__blook___1R6So-camelCase">
</div>
<span style="color: #ffffff" id="usersnamedrop">0 Users Online</span>
</div>
<i class="fas fa-angle-down styles__profileDropdownIcon___3iLIX-camelCase" aria-hidden="true"></i>
<div class="styles__profileDropdownMenu___2jUAA-camelCase" id="online-users-dropdown" style="max-height: 300px; overflow-y: auto;"></div>
</div>
`);
}
const style = document.createElement('style');
style.textContent = `
#chat-container {
height: 502px;
width: 80%;
max-width: 1114px;
overflow-y: auto;
padding: 10px;
margin-bottom: 10px;
background-color: rgba(0, 0, 0, 0);
margin-left: 220px;
}
.chat-message {
display: flex;
align-items: center;
margin-bottom: 8px;
color: #fff;
}
.styles__infoContainer___2uI-S-camelCase {
display: flex;
align-items: center;
gap: 10px;
width: 1000px;
padding: 12px;
position: fixed;
bottom: 0;
left: 25%;
transform: translateX(-10%);
}
.styles__infoContainer___2uI-S-camelCase i {
color: #fff;
font-size: 20px;
cursor: pointer;
flex-shrink: 0;
margin-bottom: -35px;
transform: translateX(-2445%);
}
#user-input {
width: 95%;
padding: 5px;
font-size: 16px;
font-family: 'Nunito', sans-serif;
background: rgba(0, 0, 0, 0.5);
color: #fff;
border: none;
outline: none;
border-radius: 5px;
display: inline-block;
transform: translateX(1%);
}
#typing-indicator {
margin: -5px 0 10px 220px;
padding-left: 20px;
font-style: italic;
color: #ffff;
font-size: 14px;
}
#new-message {
display: none;
margin: -12px 0 10px 220px;
padding-left: 400px;
font-weight: bold;
color: #fff;
font-size: 18px;
cursor: pointer;
}
.profile-link {
text-decoration: none;
color: inherit;
}
`;
document.head.appendChild(style);
const chatHTML = `
<div id="chat-container"></div>
<div id="typing-indicator"></div>
<div id="new-message"></div>
<div class="styles__infoContainer___2uI-S-camelCase">
<i class="fas fa-upload" style="cursor: pointer;" onclick="document.getElementById('fileInput').click();"></i>
<input type="file" id="fileInput" accept="image/*,video/*,audio/*" style="display: none;">
<input type="text" id="user-input" placeholder="Type a message..."/>
</div>
`;
document.body.insertAdjacentHTML('beforeend', chatHTML);
initializeChat();
}
function initializeChat() {
const firebaseScript = document.createElement('script');
firebaseScript.src = 'https://www.gstatic.com/firebasejs/8.10.0/firebase-app.js';
firebaseScript.onload = () => {
const firebaseDatabaseScript = document.createElement('script');
firebaseDatabaseScript.src = 'https://www.gstatic.com/firebasejs/8.10.0/firebase-database.js';
firebaseDatabaseScript.onload = () => {
const firebaseConfig = {
apiKey: "AIzaSyDV9tQXgzqxUayhvc384tTLOwy0QOEZVcU",
authDomain: "chat-e6c93.firebaseapp.com",
databaseURL: "https://chat-e6c93-default-rtdb.firebaseio.com",
projectId: "chat-e6c93",
storageBucket: "chat-e6c93.appspot.com",
messagingSenderId: "131547791719",
appId: "1:131547791719:web:2f567033f028810345afc2",
measurementId: "G-VY49LNJJLG"
};
const app = firebase.initializeApp(firebaseConfig);
const db = firebase.database();
const chatRef = db.ref('triangulet1/');
const typingRef = db.ref('triangulet_typing/');
const onlineRef = db.ref('triangulet_online/');
const PAGE_SIZE = 20;
let earliestTimestamp = null;
let loadingOlderMessages = false;
let loadedMessages = new Set();
let firstLoadDone = false;
const token = document.cookie
.split('; ')
.find(row => row.startsWith('tokenraw='))
?.split('=')[1];
let currentUser = "User";
let currentUserPfp = "https://i.ibb.co/5GBHSTB/Triangulet-Game-Logo.png";
let currentUserId = "";
fetch('/data/user', {
headers: {
'Authorization': decodeURIComponent(token)
}
})
.then(res => res.json())
.then(data => {
if (data.username) {
currentUser = data.username;
if (data.pfp) {
currentUserPfp = data.pfp;
}
currentUserId = data.id;
initializeChatComponents();
} else {
console.error("Username not found in response");
initializeChatComponents();
}
})
.catch(err => {
console.error("Error fetching username:", err);
initializeChatComponents();
});
function initializeChatComponents() {
const userKey = currentUser.replace(/\W+/g, "_");
const userStatusRef = onlineRef.child(userKey);
userStatusRef.set({
username: currentUser,
userId: currentUserId,
timestamp: firebase.database.ServerValue.TIMESTAMP
});
const onlineStatusInterval = setInterval(() => {
userStatusRef.update({
timestamp: firebase.database.ServerValue.TIMESTAMP
});
}, 1000);
userStatusRef.onDisconnect().remove();
const chatContainer = document.getElementById("chat-container");
const userInput = document.getElementById("user-input");
const typingIndicator = document.getElementById("typing-indicator");
const newMessageBanner = document.getElementById("new-message");
const usersNameDrop = document.getElementById("usersnamedrop");
const usersDropdown = document.getElementById("online-users-dropdown");
function updateUserListDisplay(userList) {
usersDropdown.innerHTML = "";
userList.forEach(user => {
const safeUser = escapeHtml(user.username);
const safeUserId = escapeHtml(user.userId || '');
const displayName = safeUser.length > 15 ?
escapeHtml(safeUser.slice(0, 15)) + "..." :
safeUser;
const item = document.createElement("a");
item.className = "styles__profileDropdownOption___ljZXD-camelCase profile-link";
item.href = `https://tri.pengpowers.xyz/stats?id=${safeUserId}`;
item.style.color = "#ffffff";
item.innerHTML = `
<i class="fas fa-user styles__profileDropdownOptionIcon___15VKX-camelCase" style="color: #ffffff;"></i>
<span title="${safeUser}">${displayName}</span>
`;
usersDropdown.appendChild(item);
});
usersNameDrop.textContent = `${userList.length} User${userList.length !== 1 ? 's' : ''} Online`;
usersDropdown.style.maxHeight = userList.length > 15 ? "300px" : "unset";
usersDropdown.style.overflowY = userList.length > 15 ? "auto" : "unset";
}
onlineRef.on('value', (snapshot) => {
const data = snapshot.val() || {};
const users = Object.values(data)
.filter(entry => entry.username)
.sort((a, b) => a.username.localeCompare(b.username));
updateUserListDisplay(users);
});
function sanitizeHtml(html) {
const temp = document.createElement('div');
temp.textContent = html;
return temp.innerHTML
.replace(/javascript:/gi, '')
.replace(/on\w+="[^"]*"/gi, '');
}
function escapeHtml(text) {
return text
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function formatTimestamp(timestamp) {
const date = new Date(timestamp);
const now = new Date();
const options = { hour: 'numeric', minute: '2-digit', hour12: true };
if (date.toDateString() === now.toDateString()) {
return date.toLocaleTimeString(undefined, options);
}
return date.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
...options
});
}
function renderMessage(text) {
const blockedDomains = [
"iplogger","wl.gl","ed.tc","bc.ax","maper.info","2no.co","yip.su",
"iplis.ru","ezstat.ru","iplog.co","iplogger.cn","grabify","hd.gd",
"onbit.pro","snifferip.com","unl.one","urlto.me","location.cyou",
"mymap.icu","mymap.quest","map-s.online","crypto-o.click","cryp-o.online",
"account.beauty","photospace.life","photovault.store","imagehub.fun",
"sharevault.cloud","xtube.chat","screensnaps.top","photovault.pics",
"foot.wiki","gamergirl.pro","picshost.pics","pichost.pics","imghost.pics",
"screenshare.pics","myprivate.pics","shrekis.life","screenshot.best",
"gamingfun.me","stopify.co"
];
function isBlockedUrl(url) {
try {
const lowered = url.toLowerCase();
return blockedDomains.some(domain => lowered.includes(domain));
} catch {
return false;
}
}
const trimmedText = text.trim();
const cleanText = sanitizeHtml(trimmedText);
if (cleanText.startsWith("data:")) {
const mime = cleanText.slice(5, cleanText.indexOf(";"));
if (mime.startsWith("image/")) {
return `<img src="${sanitizeHtml(cleanText)}" style="max-width: 300px; max-height: 300px; border-radius: 6px;">`;
} else if (mime.startsWith("video/")) {
return `<video controls style="max-width: 300px; max-height: 300px;">
<source src="${sanitizeHtml(cleanText)}" type="${sanitizeHtml(mime)}">
Your browser does not support the video tag.
</video>`;
} else if (mime.startsWith("audio/")) {
return `<audio controls>
<source src="${sanitizeHtml(cleanText)}" type="${sanitizeHtml(mime)}">
Your browser does not support the audio tag.
</audio>`;
}
}
const urlRegex = /^https?:\/\/[^\s]+$/i;
const imageUrlPattern = /(https?:\/\/.*\.(?:jpeg|jpg|gif|png|svg|webp|tiff|eps|bmp|avif|xcf|ico))/i;
const videoUrlPattern = /(https?:\/\/.*\.(?:avi|mov|mp4|ogg|wmv|mkv|mpg|flv|avchd|mpeg4|m2ts|webm))/i;
const audioUrlPattern = /(https?:\/\/.*\.(?:mp3|wav|aac|pcm|m4a|m4p|opus|flac|dsd|gsm|wma|ogg))/i;
if (urlRegex.test(cleanText)) {
if (isBlockedUrl(cleanText)) {
return escapeHtml(cleanText);
}
if (imageUrlPattern.test(cleanText)) {
return `<img src="${sanitizeHtml(cleanText)}" style="max-width: 300px; max-height: 300px; border-radius: 6px;">`;
} else if (videoUrlPattern.test(cleanText)) {
return `<video controls style="max-width: 300px; max-height: 300px;">
<source src="${sanitizeHtml(cleanText)}">
Your browser does not support the video tag.
</video>`;
} else if (audioUrlPattern.test(cleanText)) {
return `<audio controls>
<source src="${sanitizeHtml(cleanText)}">
Your browser does not support the audio tag.
</audio>`;
} else {
return `<a href="${escapeHtml(cleanText)}" target="_blank" rel="noopener noreferrer">${escapeHtml(cleanText)}</a>`;
}
}
return escapeHtml(cleanText);
}
function appendMessage(sender, text, timestamp, pfp, userId, prepend = false) {
const safeSender = escapeHtml(sender.length > 15 ? sender.slice(0, 15) + "..." : sender);
const safeUserId = escapeHtml(userId || '');
const mentionRegex = /@(\w{1,30})/g;
const pingSound = new Audio("https://cdn.glitch.global/a6695a81-c90d-4020-ae20-474929cf2986/Blacket%20Reply%20SFX%20(mp3cut.net)%20(1).mp3?v=1749595919054");
let containsMention = false;
const processedText = escapeHtml(text).replace(mentionRegex, (_, m) => {
const safe = escapeHtml(m);
if (safe.toLowerCase() === currentUser.toLowerCase() || safe.toLowerCase() === "everyone") {
containsMention = true;
}
return `<span style="color: blue; font-weight: bold;">@${safe}</span>`;
});
if (containsMention) {
pingSound.play().catch(() => {});
}
const safePfp = pfp ? sanitizeHtml(pfp) : 'https://i.ibb.co/5GBHSTB/Triangulet-Game-Logo.png';
const msgStyle = containsMention ?
"background-color: yellow; padding: 5px; border-radius: 4px; color: black;" :
"color: white;";
const formattedTime = formatTimestamp(timestamp);
const html = `
<div class="chat-message" style="display: flex; align-items: flex-start; margin-bottom: 15px; gap: 10px;">
<a href="https://tri.pengpowers.xyz/stats?id=${safeUserId}" class="profile-link">
<img src="${safePfp}" alt="User Icon" style="width: 50px; height: 50px; border-radius: 0;">
</a>
<div>
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 5px;">
<a href="https://tri.pengpowers.xyz/stats?id=${safeUserId}" class="profile-link">
<strong style="font-size: 1.2em; color: white;">${safeSender}</strong>
</a>
<span style="font-size: 0.85em; color: white;">${formattedTime}</span>
</div>
<span style="font-size: 1em; word-break: break-word; ${msgStyle}">
${renderMessage(processedText)}
</span>
</div>
</div>`;
if (prepend) {
const prevScroll = chatContainer.scrollHeight;
chatContainer.insertAdjacentHTML('afterbegin', html);
const diff = chatContainer.scrollHeight - prevScroll;
chatContainer.scrollTop += diff;
} else {
chatContainer.insertAdjacentHTML('beforeend', html);
if (isNearBottom()) {
chatContainer.scrollTop = chatContainer.scrollHeight;
newMessageBanner.style.display = "none";
} else {
newMessageBanner.textContent = "New messages";
newMessageBanner.style.display = "block";
}
}
}
function isNearBottom() {
return Math.abs(chatContainer.scrollHeight - chatContainer.scrollTop - chatContainer.clientHeight) < 100;
}
async function loadMessages(initial = false) {
if (loadingOlderMessages) return;
loadingOlderMessages = true;
let queryRef;
if (initial) {
queryRef = chatRef.orderByChild("timestamp").limitToLast(PAGE_SIZE);
} else if (earliestTimestamp) {
queryRef = chatRef.orderByChild("timestamp").endAt(earliestTimestamp).limitToLast(PAGE_SIZE + 1);
} else {
loadingOlderMessages = false;
return;
}
try {
const snapshot = await queryRef.once('value');
const data = snapshot.val();
if (!data) return;
let messages = Object.entries(data).map(([id, msg]) => ({ id, ...msg }));
messages.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
if (!initial) messages.pop();
for (const msg of messages) {
if (!loadedMessages.has(msg.id)) {
appendMessage(
msg.username || "User",
msg.text,
msg.timestamp,
msg.pfp,
msg.userId,
!initial
);
loadedMessages.add(msg.id);
if (!earliestTimestamp || new Date(msg.timestamp) < new Date(earliestTimestamp)) {
earliestTimestamp = msg.timestamp;
}
}
}
if (initial) {
setTimeout(() => {
chatContainer.scrollTop = chatContainer.scrollHeight;
}, 0);
firstLoadDone = true;
}
} finally {
loadingOlderMessages = false;
}
}
chatRef.on('child_added', (snapshot) => {
const msg = snapshot.val();
const id = snapshot.key;
if (!loadedMessages.has(id) && firstLoadDone) {
appendMessage(
msg.username || "User",
msg.text,
msg.timestamp,
msg.pfp,
msg.userId
);
loadedMessages.add(id);
}
});
chatContainer.addEventListener("scroll", () => {
if (chatContainer.scrollTop < 100 && !loadingOlderMessages) {
loadMessages(false);
}
if (isNearBottom()) {
newMessageBanner.style.display = "none";
}
});
newMessageBanner.addEventListener("click", () => {
chatContainer.scrollTop = chatContainer.scrollHeight;
newMessageBanner.style.display = "none";
});
let typingTimeout;
userInput.addEventListener("input", () => {
typingRef.child(userKey).set({
username: currentUser,
timestamp: firebase.database.ServerValue.TIMESTAMP
});
clearTimeout(typingTimeout);
typingTimeout = setTimeout(() => {
typingRef.child(userKey).remove();
}, 3000);
});
typingRef.on('value', (snapshot) => {
const data = snapshot.val() || {};
const typers = Object.values(data)
.filter(entry => entry.username && entry.username.toLowerCase() !== currentUser.toLowerCase())
.map(entry => escapeHtml(entry.username));
if (typers.length === 0) {
typingIndicator.textContent = "";
} else if (typers.length === 1) {
typingIndicator.textContent = `${typers[0]} is typing...`;
} else {
const displayed = typers.slice(0, 2).join(", ");
const remaining = typers.length - 2;
typingIndicator.textContent = remaining > 0 ?
`${displayed}, and ${remaining} more are typing...` :
`${displayed} are typing...`;
}
});
userInput.addEventListener("keypress", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
async function sendMessage() {
const text = userInput.value.trim();
if (!text) return;
try {
await chatRef.push({
text,
username: currentUser,
pfp: currentUserPfp,
userId: currentUserId,
timestamp: firebase.database.ServerValue.TIMESTAMP
});
userInput.value = '';
typingRef.child(userKey).remove();
} catch (err) {
console.error("Error sending message:", err);
}
}
const fileInput = document.getElementById("fileInput");
fileInput.addEventListener("change", (event) => {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const base64 = e.target.result;
chatRef.push({
text: base64,
username: currentUser,
pfp: currentUserPfp,
userId: currentUserId,
timestamp: firebase.database.ServerValue.TIMESTAMP
});
};
reader.readAsDataURL(file);
});
loadMessages(true);
}
};
document.head.appendChild(firebaseDatabaseScript);
};
document.head.appendChild(firebaseScript);
}
})();