mirror of
https://github.com/litruv/ChatVelocity.git
synced 2026-07-24 02:36:06 +10:00
Initial
This commit is contained in:
26
public/index.html
Normal file
26
public/index.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Twitch Consolidation</title>
|
||||
<link href="/styles.css" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/tmi.js/dist/tmi.min.js"></script>
|
||||
<script src="/script.js" defer></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="settings-box">
|
||||
<label for="minWords">Min Words:</label>
|
||||
<input type="number" id="minWords" value="1" min="1">
|
||||
|
||||
<label for="columns">Columns:</label>
|
||||
<input type="number" id="columns" value="1" min="1">
|
||||
|
||||
<button id="apply-settings">Apply</button>
|
||||
</div>
|
||||
<div id="chat-container"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
310
public/script.js
Normal file
310
public/script.js
Normal file
@@ -0,0 +1,310 @@
|
||||
class ChatApp {
|
||||
constructor(chatContainerId, maxMessages = 100) {
|
||||
this.chatContainer = document.getElementById(chatContainerId);
|
||||
this.MAX_MESSAGES = maxMessages;
|
||||
this.messageMap = new Map();
|
||||
this.currentColumnIndex = 0;
|
||||
this.columns = parseInt(ChatApp.getQueryParam('columns')) || 1;
|
||||
this.MIN_WORDS = parseInt(ChatApp.getQueryParam('minWords')) || 1;
|
||||
this.isHovering = new Array(this.columns).fill(false);
|
||||
this.messageCounter = 0;
|
||||
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
static getQueryParam(param) {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return urlParams.get(param);
|
||||
}
|
||||
|
||||
async initialize(username) {
|
||||
if (!username) {
|
||||
console.error("No 'user' query parameter provided.");
|
||||
alert("Please provide a Twitch username in the URL, e.g., ?user=zackrawrr");
|
||||
throw new Error("Missing 'user' query parameter.");
|
||||
}
|
||||
|
||||
try {
|
||||
const { userId, twitchEmotes } = await this.fetchTwitchUserData(username);
|
||||
const sevenTvEmotes = await this.fetch7TVEmotes(userId);
|
||||
|
||||
await this.setupColumns();
|
||||
await this.setupTmiClient(username, twitchEmotes, sevenTvEmotes);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('An error occurred. Please try again later.');
|
||||
}
|
||||
}
|
||||
|
||||
async fetchTwitchUserData(username) {
|
||||
const response = await fetch(`https://twitchapi.ruv.wtf/twitch/userdata?username=${username}`);
|
||||
if (!response.ok) throw new Error(`Failed to fetch Twitch data: ${response.statusText}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async fetch7TVEmotes(userId) {
|
||||
try {
|
||||
const response = await fetch(`https://7tv.io/v3/users/twitch/${userId}`);
|
||||
if (!response.ok) throw new Error(`Error fetching 7TV emotes: ${response.status}`);
|
||||
|
||||
const emotesData = await response.json();
|
||||
if (!emotesData?.emote_set?.emotes) return {};
|
||||
|
||||
return emotesData.emote_set.emotes.reduce((acc, emote) => {
|
||||
acc[emote.name] = `https://cdn.7tv.app/emote/${emote.id}/1x.webp`;
|
||||
return acc;
|
||||
}, {});
|
||||
} catch (error) {
|
||||
console.error("Error in fetch7TVEmotes:", error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async setupTmiClient(username, twitchEmotes, sevenTvEmotes) {
|
||||
const client = new tmi.Client({ channels: [username] });
|
||||
|
||||
await client.connect();
|
||||
|
||||
client.on('message', (channel, tags, message, self) => {
|
||||
if (self || message.startsWith('!') || tags.badges?.broadcaster || tags.badges?.moderator) return;
|
||||
|
||||
const wordCount = message.split(/\s+/).filter(word => word.length > 0).length;
|
||||
if (wordCount < this.MIN_WORDS) return;
|
||||
|
||||
const parsedMessage = this.parseEmotes(message, tags.emotes || {}, sevenTvEmotes);
|
||||
const userData = { name: tags.username, color: tags.color || '#ffffff' };
|
||||
|
||||
this.addMessageToChat(userData.name, userData.color, parsedMessage);
|
||||
});
|
||||
}
|
||||
|
||||
async setupColumns() {
|
||||
this.chatContainer.innerHTML = '';
|
||||
for (let i = 0; i < this.columns; i++) {
|
||||
const column = document.createElement('div');
|
||||
column.id = `column-${i}`;
|
||||
column.className = 'column';
|
||||
this.chatContainer.appendChild(column);
|
||||
}
|
||||
|
||||
this.setupColumnHover();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
document.getElementById('apply-settings').addEventListener('click', () => {
|
||||
this.MIN_WORDS = parseInt(document.getElementById('minWords').value, 10);
|
||||
this.columns = parseInt(document.getElementById('columns').value, 10);
|
||||
this.updateColumns();
|
||||
});
|
||||
}
|
||||
|
||||
updateColumns() {
|
||||
const currentColumns = this.chatContainer.querySelectorAll('.column');
|
||||
const currentColumnCount = currentColumns.length;
|
||||
|
||||
if (this.columns > currentColumnCount) {
|
||||
for (let i = currentColumnCount; i < this.columns; i++) {
|
||||
this.createColumn(i);
|
||||
}
|
||||
} else if (this.columns < currentColumnCount) {
|
||||
this.rebuildColumns(currentColumns);
|
||||
}
|
||||
|
||||
this.setupColumnHover();
|
||||
}
|
||||
|
||||
createColumn(index) {
|
||||
const column = document.createElement('div');
|
||||
column.id = `column-${index}`;
|
||||
column.className = 'column';
|
||||
this.chatContainer.appendChild(column);
|
||||
}
|
||||
|
||||
rebuildColumns(currentColumns) {
|
||||
const allMessages = [];
|
||||
|
||||
currentColumns.forEach(column => {
|
||||
Array.from(column.children).forEach(message => allMessages.push(message));
|
||||
column.remove();
|
||||
});
|
||||
|
||||
for (let i = 0; i < this.columns; i++) {
|
||||
this.createColumn(i);
|
||||
}
|
||||
|
||||
allMessages.forEach((message, index) => {
|
||||
const targetColumn = this.chatContainer.querySelector(`#column-${index % this.columns}`);
|
||||
targetColumn.appendChild(message);
|
||||
});
|
||||
}
|
||||
|
||||
setupColumnHover() {
|
||||
for (let i = 0; i < this.columns; i++) {
|
||||
const column = document.getElementById(`column-${i}`);
|
||||
column.addEventListener('mouseenter', () => this.isHovering[i] = true);
|
||||
column.addEventListener('mouseleave', () => this.isHovering[i] = false);
|
||||
}
|
||||
}
|
||||
|
||||
scrollToBottom(columnIndex) {
|
||||
if (!this.isHovering[columnIndex]) {
|
||||
const chatColumn = document.getElementById(`column-${columnIndex}`);
|
||||
chatColumn.scrollTo({ top: chatColumn.scrollHeight, behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
addMessageToChat(displayName, color, message) {
|
||||
const hash = ChatApp.hashMessage(message);
|
||||
let messageElement;
|
||||
const userData = { name: displayName, color };
|
||||
|
||||
let columnIndex;
|
||||
|
||||
if (this.messageMap.has(hash)) {
|
||||
const messageData = this.messageMap.get(hash);
|
||||
columnIndex = messageData.element.getAttribute('data-column-index');
|
||||
messageData.users.push(userData);
|
||||
messageElement = messageData.element;
|
||||
this.updateMessageContent(messageElement, messageData);
|
||||
} else {
|
||||
columnIndex = this.messageCounter % this.columns;
|
||||
this.messageCounter++;
|
||||
|
||||
messageElement = document.createElement('div');
|
||||
messageElement.className = 'chat-message';
|
||||
messageElement.setAttribute('data-hash', hash);
|
||||
messageElement.setAttribute('data-column-index', columnIndex);
|
||||
|
||||
const messageData = { message, users: [userData], element: messageElement };
|
||||
this.messageMap.set(hash, messageData);
|
||||
this.updateMessageContent(messageElement, messageData);
|
||||
}
|
||||
|
||||
const column = document.getElementById(`column-${columnIndex}`);
|
||||
column.appendChild(messageElement);
|
||||
|
||||
while (column.children.length > this.MAX_MESSAGES) {
|
||||
const oldestMessage = column.firstChild;
|
||||
const oldHash = oldestMessage.getAttribute('data-hash');
|
||||
this.messageMap.delete(oldHash);
|
||||
column.removeChild(oldestMessage);
|
||||
}
|
||||
|
||||
this.scrollToBottom(columnIndex);
|
||||
}
|
||||
|
||||
static hashMessage(str) {
|
||||
str = str.toLowerCase()
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/(<img\s*src="[^"]+"[^>]*>)/g, (match) => {
|
||||
return '<img />';
|
||||
})
|
||||
.replace(/(<[^>]+>)/g, (match) => match.trim())
|
||||
.split(' ')
|
||||
.filter((word, index, words) => word !== words[index - 1])
|
||||
.join('')
|
||||
.replace(/[^a-z0-9\s<>/]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/(.)\1+/g, '$1');
|
||||
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash ^= str.charCodeAt(i);
|
||||
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
|
||||
}
|
||||
|
||||
return (hash >>> 0).toString(16).padStart(8, '0').slice(-8);
|
||||
}
|
||||
|
||||
formatMessage(message) {
|
||||
return message.replace(/(@\w+)/g, '<span class="mention">$1</span>');
|
||||
}
|
||||
|
||||
parseEmotes(message, twitchEmotes = {}, sevenTvEmotes = {}) {
|
||||
if (!twitchEmotes && !sevenTvEmotes) return message;
|
||||
|
||||
let messageArray = [...message];
|
||||
|
||||
if (twitchEmotes && Object.keys(twitchEmotes).length > 0) {
|
||||
const positionMap = new Map();
|
||||
|
||||
Object.entries(twitchEmotes).forEach(([emoteId, positions]) => {
|
||||
if (!Array.isArray(positions)) return;
|
||||
|
||||
positions.forEach(position => {
|
||||
if (typeof position !== 'string') return;
|
||||
|
||||
const [start, end] = position.split('-').map(Number);
|
||||
if (isNaN(start) || isNaN(end)) return;
|
||||
|
||||
const emoteText = message.slice(start, end + 1);
|
||||
const emoteImg = `<img src="https://static-cdn.jtvnw.net/emoticons/v2/${emoteId}/default/dark/1.0" class="emote twitch-emote" alt="${emoteText}">`;
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
positionMap.set(i, i === start ? emoteImg : '');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
messageArray = messageArray.map((char, index) =>
|
||||
positionMap.has(index) ? positionMap.get(index) : char
|
||||
);
|
||||
}
|
||||
|
||||
let parsedMessage = messageArray.join('');
|
||||
|
||||
if (sevenTvEmotes && Object.keys(sevenTvEmotes).length > 0) {
|
||||
Object.entries(sevenTvEmotes).forEach(([emoteName, emoteUrl]) => {
|
||||
// Escape special characters in the emote name for use in regex
|
||||
const escapedEmoteName = emoteName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
try {
|
||||
const emoteRegEx = new RegExp(`\\b${escapedEmoteName}\\b`, 'g');
|
||||
parsedMessage = parsedMessage.replace(
|
||||
emoteRegEx,
|
||||
`<img src="${emoteUrl}" class="emote seventv-emote" alt="${emoteName}">`
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to create regex for emote: ${emoteName}`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return parsedMessage;
|
||||
}
|
||||
|
||||
|
||||
updateMessageContent(messageElement, messageData) {
|
||||
const { message, users } = messageData;
|
||||
const uniqueUsers = new Set(users);
|
||||
|
||||
messageElement.innerHTML = '';
|
||||
|
||||
if (uniqueUsers.size === 1) {
|
||||
const [firstUser] = users;
|
||||
const usernameSpan = document.createElement('span');
|
||||
usernameSpan.className = 'username';
|
||||
usernameSpan.style.color = firstUser.color;
|
||||
usernameSpan.textContent = firstUser.name;
|
||||
messageElement.appendChild(usernameSpan);
|
||||
messageElement.appendChild(document.createTextNode(': '));
|
||||
}
|
||||
|
||||
messageElement.insertAdjacentHTML('beforeend', this.formatMessage(message));
|
||||
|
||||
if (users.length > 1) {
|
||||
const counter = document.createElement('span');
|
||||
counter.className = 'duplicate-count';
|
||||
|
||||
const intensity = Math.min(1, Math.log(users.length) / 4);
|
||||
counter.style.backgroundColor = `rgb(${255 * intensity}, ${255 - 255 * intensity}, 50)`;
|
||||
counter.textContent = `×${users.length}`;
|
||||
messageElement.appendChild(counter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const username = ChatApp.getQueryParam('user');
|
||||
const app = new ChatApp('chat-container');
|
||||
await app.initialize(username);
|
||||
})();
|
||||
105
public/styles.css
Normal file
105
public/styles.css
Normal file
@@ -0,0 +1,105 @@
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #0e0e10;
|
||||
color: #efeff1;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
#chat-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.column {
|
||||
flex: 1;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
border: 1px solid #2d2d2d;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
background: #18181b;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
padding: 4px 8px;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.chat-message:hover {
|
||||
background: #1f1f23;
|
||||
}
|
||||
|
||||
.emote {
|
||||
vertical-align: middle;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-weight: bold;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.mention {
|
||||
color: #00BFFF;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.duplicate-count {
|
||||
display: inline-block;
|
||||
background: #2d2d2d;
|
||||
color: #efeff1;
|
||||
padding: 0 6px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.8em;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
#settings-box {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #2d2d2d;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
#settings-box label {
|
||||
font-weight: bold;
|
||||
color: #efeff1;
|
||||
}
|
||||
|
||||
#settings-box input {
|
||||
width: 50px;
|
||||
padding: 5px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ccc;
|
||||
background: #18181b;
|
||||
color: #efeff1;
|
||||
}
|
||||
|
||||
#settings-box button {
|
||||
padding: 5px 10px;
|
||||
background: #444;
|
||||
color: #efeff1;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#settings-box button:hover {
|
||||
background: #555;
|
||||
}
|
||||
Reference in New Issue
Block a user