diff --git a/backend/src/config.ts b/backend/src/config.ts index f434e41..f9169e0 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -6,7 +6,7 @@ const EnvSchema = z.object({ HOST: z.string().default('0.0.0.0'), DATABASE_URL: z.string().url(), LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'), - NICKNAME_RATE_LIMIT_SECS: z.coerce.number().int().positive().default(300), + NICKNAME_RATE_LIMIT_SECS: z.coerce.number().int().positive().default(10), }); export const config = EnvSchema.parse(process.env); diff --git a/extension/content/twitch.js b/extension/content/twitch.js index 4552da9..60c5c91 100644 --- a/extension/content/twitch.js +++ b/extension/content/twitch.js @@ -19,103 +19,63 @@ btn.setAttribute('aria-label', 'Otevřít UnityChat'); btn.title = 'Otevřít UnityChat'; Object.assign(btn.style, { - display: 'inline-flex', + display: 'flex', alignItems: 'center', justifyContent: 'center', width: '30px', height: '30px', - padding: '4px', - margin: '0 4px', + minWidth: '30px', + padding: '0', + margin: '0 2px', background: 'transparent', border: 'none', borderRadius: '4px', cursor: 'pointer', flexShrink: '0', - transition: 'background 0.15s ease, transform 0.15s ease' + transition: 'background 0.15s ease' }); - const img = document.createElement('img'); img.src = chrome.runtime.getURL('icons/icon48.png'); - img.alt = 'UnityChat'; - Object.assign(img.style, { - width: '22px', - height: '22px', - display: 'block', - filter: 'drop-shadow(0 0 6px rgba(255, 140, 0, 0.55))', - pointerEvents: 'none' - }); + img.alt = 'UC'; + Object.assign(img.style, { width: '20px', height: '20px', display: 'block', pointerEvents: 'none' }); btn.appendChild(img); - - btn.addEventListener('mouseenter', () => { - btn.style.background = 'rgba(255, 140, 0, 0.12)'; - img.style.filter = 'drop-shadow(0 0 10px rgba(255, 160, 20, 0.8))'; - }); - btn.addEventListener('mouseleave', () => { - btn.style.background = 'transparent'; - img.style.filter = 'drop-shadow(0 0 6px rgba(255, 140, 0, 0.55))'; - }); + btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(255,140,0,0.15)'; }); + btn.addEventListener('mouseleave', () => { btn.style.background = 'transparent'; }); btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); + // Collapse vanilla Twitch chat + const collapseBtn = document.querySelector('[data-a-target="right-column__toggle-collapse-btn"]'); + if (collapseBtn) collapseBtn.click(); + // Open UnityChat side panel chrome.runtime.sendMessage({ type: 'OPEN_SIDE_PANEL' }).catch(() => {}); }); return btn; } function findChatHeader() { - // Twitch uses several header classes across layouts; try them in order. - const selectors = [ - '.stream-chat-header', - '[data-a-target="stream-chat-header"]', - '.chat-room__header', - '.chat-shell__header', - '.chat-header' - ]; - for (const sel of selectors) { - const el = document.querySelector(sel); - if (el) return el; - } - // Fallback: climb from the collapse toggle button. - const toggle = document.querySelector('[data-a-target="right-column__toggle-collapse-btn"]'); - if (toggle) { - // Walk up until we find a row-ish container (flex parent with siblings). - let n = toggle.parentElement; - for (let i = 0; i < 5 && n; i++) { - if (n.childElementCount >= 2) return n; - n = n.parentElement; - } - } - return null; + return document.querySelector('.stream-chat-header'); } function injectSidePanelButton() { - if (document.getElementById(UC_BTN_ID)) return; - const header = findChatHeader(); + if (document.querySelectorAll('#' + UC_BTN_ID).length > 0) return; + const header = document.querySelector('.stream-chat-header'); if (!header) return; - const btn = buildUcButton(); - // Prefer to sit right after the collapse toggle (matches the red-box - // position in the UI). Otherwise prepend into the header row. - const toggle = header.querySelector('[data-a-target="right-column__toggle-collapse-btn"]'); - if (toggle && toggle.parentElement) { - toggle.parentElement.insertBefore(btn, toggle.nextSibling); - } else { - header.insertBefore(btn, header.firstChild); + const label = header.querySelector('#chat-room-header-label'); + if (!label) return; + // label is inside a wrapper div — insert button before that wrapper + const wrapper = label.parentElement; + if (wrapper && wrapper.parentElement === header) { + header.insertBefore(buildUcButton(), wrapper); } } - function startHeaderObserver() { - injectSidePanelButton(); - const obs = new MutationObserver(() => { - if (!document.getElementById(UC_BTN_ID)) injectSidePanelButton(); - }); - obs.observe(document.body, { childList: true, subtree: true }); - } - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', startHeaderObserver, { once: true }); - } else { - startHeaderObserver(); - } + // Poll for header — Twitch re-mounts on navigation + setInterval(() => { + if (document.querySelectorAll('#' + UC_BTN_ID).length === 0) { + injectSidePanelButton(); + } + }, 2000); chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { if (msg.type === 'PING') { diff --git a/extension/manifest.json b/extension/manifest.json index fe09444..35ed8a8 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "UnityChat", - "version": "3.18.7", + "version": "3.18.35", "description": "Sjednocený chat z Twitch, YouTube a Kick v jednom panelu", "permissions": [ "sidePanel", diff --git a/extension/sidepanel.css b/extension/sidepanel.css index 460ed0f..1c738c2 100644 --- a/extension/sidepanel.css +++ b/extension/sidepanel.css @@ -238,11 +238,15 @@ body.layout-large .reply-ctx { font-size: 12px; } /* ---- Nickname setting ---- */ -.nickname-row { +.save-row { display: flex; - gap: 6px; + justify-content: center; + margin-top: 8px; +} +#input-username[readonly] { + opacity: 0.5; + cursor: not-allowed; } -.nickname-row input { flex: 1; } .btn-nick { background: var(--accent-gradient); color: #000; @@ -625,9 +629,6 @@ body.layout-large .reply-ctx { font-size: 12px; } font-size: 11px; color: var(--text-muted); padding: 1px 0 1px 18px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; opacity: 0.7; } diff --git a/extension/sidepanel.html b/extension/sidepanel.html index 2b3bdd1..ed77546 100644 --- a/extension/sidepanel.html +++ b/extension/sidepanel.html @@ -23,31 +23,31 @@ Kanály
- +
- +
- +
- - + +
-
- - -
+
- + +
+
+
@@ -99,7 +99,6 @@ KI
- diff --git a/extension/sidepanel.js b/extension/sidepanel.js index 627fbf4..5ba4f3f 100644 --- a/extension/sidepanel.js +++ b/extension/sidepanel.js @@ -1271,10 +1271,12 @@ class UnityChat { this._msgCache = []; this._cacheTimer = null; this._twitchBadges = {}; - this._chatUsers = new Map(); + this._chatUsers = new Map(); // username → { name, platform, color } this._seenMsgIds = new Set(); this._seenContentKeys = new Set(); // pro scrape dedup (username + text) + this._optimisticKeys = new Map(); // contentKey → sentId (for upgrading optimistic → real) this._platformUsernames = {}; // per-platform username tracking (loaded from config in _init) + this._platformColors = {}; // per-platform user color (from IRC/API) // Uložit cache okamžitě při zavření/reloadu panelu window.addEventListener('beforeunload', () => { @@ -1303,6 +1305,18 @@ class UnityChat { if (this.config._platformUsernames) { this._platformUsernames = { ...this.config._platformUsernames }; } + if (this.config._platformColors) { + this._platformColors = { ...this.config._platformColors }; + } + // Load persisted user colors + try { + const d = await chrome.storage.local.get('uc_user_colors'); + if (d.uc_user_colors) { + for (const [k, v] of Object.entries(d.uc_user_colors)) { + this._chatUsers.set(k, v); + } + } + } catch {} await this.nicknames.loadCache(); this.nicknames.fetchAll(); // non-blocking, fire-and-forget this.nicknames.connectSSE(); @@ -1345,25 +1359,27 @@ class UnityChat { } catch {} } - console.log('[UC] init: loading cache + connecting...'); - // Load cache + connect FIRST (instant), emotes in background - await this._loadCachedMessages(); - console.log('[UC] init: cache loaded, connecting all...'); - this._connectAll(); - console.log('[UC] init: connected, starting detect loop'); - this._detectLoop(); - - // Load emotes + badges in background (don't block the UI) - this.emotes.loadGlobal().then(() => { + console.log('[UC] init: loading emotes + badges...'); + // Load emotes + badges FIRST so cached messages render with correct emotes/badges. + // Use Promise.allSettled — one failing source shouldn't block the rest. + try { + await this.emotes.loadGlobal(); if (this.config._roomId) { - return Promise.all([ + await Promise.allSettled([ this.emotes.loadChannel('twitch', this.config._roomId), this.emotes.loadBTTV(this.config._roomId), this.emotes.loadFFZ(this.config._roomId), this._loadTwitchBadges(this.config._roomId) ]); } - }).catch(() => {}); + } catch {} + + console.log('[UC] init: loading cache...'); + await this._loadCachedMessages(); + console.log('[UC] init: cache loaded, connecting all...'); + this._connectAll(); + console.log('[UC] init: connected, starting detect loop'); + this._detectLoop(); } // ---- Config ---- @@ -1445,11 +1461,7 @@ class UnityChat { }); // Username se nastaví okamžitě při psaní, uloží při blur - $('input-username').addEventListener('input', () => { - const val = $('input-username').value.trim(); - this.config.username = val; - if (this.activePlatform) this._platformUsernames[this.activePlatform] = val; - }); + // Username change (only in dev mode — field is readonly otherwise) $('input-username').addEventListener('change', () => { const val = $('input-username').value.trim(); this.config.username = val; @@ -1498,11 +1510,9 @@ class UnityChat { let lastError = null; for (const p of platforms) { - let uname = null; - // Try config username first (always set for Twitch) - if (p === 'twitch' && this.config.username) { - uname = this.config.username; - } else { + // Use platform-specific username, falling back to config username + let uname = this._platformUsernames[p] || this.config.username; + if (!uname) { // PING active tab for this platform try { const tabs = await chrome.tabs.query({ url: [tabUrls[p]] }); @@ -1513,9 +1523,16 @@ class UnityChat { } catch {} } if (!uname) continue; - const result = nick - ? await this.nicknames.save(p, uname, nick, color) - : await this.nicknames.remove(p, uname); + let result; + if (nick || color) { + // If no custom nickname, use the display name (from IRC display-name tag) + // so it looks unchanged — only color changes + const displayName = nick || this._chatUsers.get(`${p}:${uname.toLowerCase()}`)?.name || uname; + result = await this.nicknames.save(p, uname, displayName, color); + } else { + // Both empty → delete + result = await this.nicknames.remove(p, uname); + } if (result.ok) saved++; else if (result.retryAfter) lastError = `Počkej ${Math.ceil(result.retryAfter)}s`; else lastError = result.error; @@ -1523,8 +1540,9 @@ class UnityChat { $('btn-nickname').disabled = false; if (saved > 0) { - statusEl.textContent = nick - ? `Přezdívka uložena pro ${saved} ${saved === 1 ? 'platformu' : 'platformy'}!` + const what = nick ? 'Přezdívka' : color ? 'Barva' : 'Přezdívka smazána'; + statusEl.textContent = nick || color + ? `${what} uložena pro ${saved} ${saved === 1 ? 'platformu' : 'platformy'}!` : `Přezdívka smazána pro ${saved} ${saved === 1 ? 'platformu' : 'platformy'}`; statusEl.className = 'nick-status success'; } else { @@ -1565,7 +1583,10 @@ class UnityChat { // Dev mode $('chk-devmode').addEventListener('change', () => { - $('dev-tools').classList.toggle('hidden', !$('chk-devmode').checked); + const on = $('chk-devmode').checked; + $('dev-tools').classList.toggle('hidden', !on); + // Enable/disable username editing + $('input-username').readOnly = !on; }); $('btn-dump-cache').addEventListener('click', () => { chrome.storage.local.get(this._cacheKey, (d) => { @@ -1620,14 +1641,7 @@ class UnityChat { document.querySelectorAll('.fbtn').forEach((btn) => { btn.addEventListener('click', () => { const p = btn.dataset.platform; - if (p === 'all') { - const all = Object.values(this.filters).every(Boolean); - this.filters.twitch = !all; - this.filters.youtube = !all; - this.filters.kick = !all; - } else { - this.filters[p] = !this.filters[p]; - } + this.filters[p] = !this.filters[p]; this._applyFilters(); }); }); @@ -1682,11 +1696,7 @@ class UnityChat { _applyFilters() { document.querySelectorAll('.fbtn').forEach((btn) => { - const p = btn.dataset.platform; - btn.classList.toggle( - 'active', - p === 'all' ? Object.values(this.filters).every(Boolean) : this.filters[p] - ); + btn.classList.toggle('active', !!this.filters[btn.dataset.platform]); }); this.chatEl.querySelectorAll('.msg').forEach((el) => { el.classList.toggle('hide-platform', !this.filters[el.dataset.platform]); @@ -1724,11 +1734,20 @@ class UnityChat { let matches; if (partial.startsWith('@')) { // @username autocomplete (@ samotné = všichni uživatelé) + // Deduplicate by display name (map has both plain + platform:username keys) const prefix = partial.substring(1).toLowerCase(); - matches = [...this._chatUsers.values()] - .filter(u => !prefix || u.name.toLowerCase().startsWith(prefix)) - .sort((a, b) => a.name.localeCompare(b.name)) - .map(u => '@' + u.name); + const seen = new Set(); + matches = [...this._chatUsers.entries()] + .filter(([key, u]) => { + if (key.includes(':')) return false; + const name = u.name.replace(/^@/, '').toLowerCase(); + if (seen.has(name)) return false; + if (prefix && !name.startsWith(prefix)) return false; + seen.add(name); + return true; + }) + .sort(([, a], [, b]) => a.name.localeCompare(b.name)) + .map(([, u]) => '@' + u.name.replace(/^@/, '')); } else { // Emote autocomplete matches = this.emotes.findCompletions(partial); @@ -1920,7 +1939,25 @@ class UnityChat { } catch {} } + _savePlatformColor(platform, color) { + if (this._platformColors[platform] === color) return; + this._platformColors[platform] = color; + if (!this.config._platformColors) this.config._platformColors = {}; + this.config._platformColors[platform] = color; + this._saveConfig(); + // Retroactively apply to all visible messages from this user + const myName = (this._platformUsernames[platform] || this.config.username || '').toLowerCase(); + if (myName) { + this.chatEl.querySelectorAll('.un').forEach((un) => { + if (un.dataset.platform === platform && un.dataset.username === myName) { + un.style.color = color; + } + }); + } + } + _setActivePlatform(platform) { + const changed = this.activePlatform !== platform; this.activePlatform = platform; if (!this.platformBadge) return; @@ -1944,6 +1981,10 @@ class UnityChat { ? `Zpráva do ${platform.charAt(0).toUpperCase() + platform.slice(1)}...` : 'Otevři stream pro odesílání...'; + // Only update settings fields when platform actually changes + // (detect loop runs every 3s — without this guard it overwrites user-typed values) + if (!changed) return; + // Update username field to show current platform's username const el = document.getElementById('input-username'); const label = document.querySelector('label[for="input-username"]'); @@ -1959,15 +2000,17 @@ class UnityChat { const colorHexEl = document.getElementById('input-color-hex'); const colorPickerEl = document.getElementById('input-color-picker'); if (nickEl && platform) { - const pName = this._platformUsernames[platform]; + const pName = this._platformUsernames[platform] || this.config.username; const profile = pName ? this.nicknames.get(platform, pName) : null; nickEl.value = profile?.nickname || ''; - if (colorHexEl) colorHexEl.value = profile?.color || ''; - if (colorPickerEl) colorPickerEl.value = profile?.color || '#ff8c00'; + if (profile?.color) { + if (colorHexEl) colorHexEl.value = profile.color; + if (colorPickerEl) colorPickerEl.value = profile.color; + } } if (label) { const names = { twitch: 'Twitch', youtube: 'YouTube', kick: 'Kick' }; - label.textContent = platform ? `Username (${names[platform] || platform})` : 'Tvoje username'; + label.textContent = platform ? `USERNAME (${(names[platform] || platform).toUpperCase()})` : 'USERNAME'; } } @@ -2158,8 +2201,8 @@ class UnityChat { // ---- Odpovědi na zprávy ---- - _setReply(platform, username, messageId) { - this._reply = { platform, username, messageId }; + _setReply(platform, username, messageId, message) { + this._reply = { platform, username, messageId, message }; let el = document.getElementById('reply-indicator'); if (!el) { @@ -2204,7 +2247,6 @@ class UnityChat { // Optimistic UI: show message instantly (include @mention for cross-platform reply) const username = this._platformUsernames[platform] || this.config.username || 'me'; const ucProfile = this.nicknames.get(platform, username); - const defaultColors = { twitch: '#9146ff', youtube: '#ff4b4b', kick: '#53fc18' }; let displayText = text; if (reply && reply.platform !== platform) { const at = `@${reply.username}`; @@ -2216,11 +2258,11 @@ class UnityChat { platform, username, message: displayText, - color: ucProfile?.color || this._lastUserColor || defaultColors[platform] || null, + color: ucProfile?.color || this._chatUsers.get(`${platform}:${username.toLowerCase()}`)?.color || this._platformColors?.[platform] || null, timestamp: Date.now(), _uc: true, _optimistic: true, - ...(reply ? { replyTo: { id: reply.messageId, username: reply.username } } : {}), + ...(reply ? { replyTo: { id: reply.messageId, username: reply.username, message: reply.message || null } } : {}), }); // Send in background (don't block UI) @@ -2356,16 +2398,20 @@ class UnityChat { // ---- Status ---- _status(platform, status, detail) { - const dot = document.querySelector(`#st-${platform} .dot`); + const stEl = document.getElementById(`st-${platform}`); + const dot = stEl?.querySelector('.dot'); if (!dot) return; dot.className = 'dot'; + const name = { twitch: 'Twitch', youtube: 'YouTube', kick: 'Kick' }[platform] || platform; if (status === 'connected') { dot.classList.add('connected'); - // Jen tiché připojení - indikátor stačí + if (stEl) stEl.title = `${name} - Connected`; } else if (status === 'connecting') { dot.classList.add('connecting'); + if (stEl) stEl.title = `${name} - Connecting...`; } else if (status === 'error') { dot.classList.add('error'); + if (stEl) stEl.title = `${name} - Disconnected`; this._sys(`${platform.toUpperCase()}: ${detail || 'chyba připojení'}`); } } @@ -2381,6 +2427,41 @@ class UnityChat { } _addMessage(msg) { + // Track color BEFORE dedup (echo gets deduped but we still want the color) + if (msg.color && msg.username && !msg._optimistic) { + const colorKey = `${msg.platform}:${msg.username.toLowerCase()}`; + const prev = this._chatUsers.get(colorKey); + if (!prev || prev.color !== msg.color) { + this._chatUsers.set(colorKey, { name: msg.username, platform: msg.platform, color: msg.color }); + } + // Also set plain username key for @autocomplete + this._chatUsers.set(msg.username.toLowerCase(), { name: msg.username, platform: msg.platform, color: msg.color }); + // Only track platform color for the current user's OWN messages + // (previously this ran for every message → _platformColors got overwritten + // with other users' colors → optimistic messages got wrong color) + { + const myName = (this._platformUsernames[msg.platform] || this.config.username || '').toLowerCase(); + if (msg.platform && myName && msg.username.toLowerCase() === myName) { + this._savePlatformColor(msg.platform, msg.color); + // Update platform username with display-name casing from IRC + // (PING returns login "jouki728", IRC has display-name "Jouki728") + if (msg.username !== this._platformUsernames[msg.platform]) { + this._platformUsernames[msg.platform] = msg.username; + if (!this.config._platformUsernames) this.config._platformUsernames = {}; + this.config._platformUsernames[msg.platform] = msg.username; + this._saveConfig(); + // Update username field if settings are open + const el = document.getElementById('input-username'); + if (el) el.value = msg.username; + } + } + } + if (this._lastSentText && msg.message) { + const cleanMsg = msg.message.replace(' ' + UC_MARKER, '').replace(UC_MARKER, ''); + if (cleanMsg === this._lastSentText) this._lastSentText = null; + } + } + // Dedup podle ID (cache + live zprávy) if (msg.id) { if (this._seenMsgIds.has(msg.id)) return; @@ -2403,10 +2484,26 @@ class UnityChat { ? norm(msg.username) + '|' + norm(msg.message) : null; if (contentKey) { - // Drop duplicates: scraped messages always, live messages if we already - // have an optimistic (sent) version with the same content - if (this._seenContentKeys.has(contentKey) && (msg.scraped || !msg._optimistic)) return; - this._seenContentKeys.add(contentKey); + if (this._seenContentKeys.has(contentKey)) { + if (msg._optimistic) { + // Optimistic messages always pass through — user can send same text twice + // (e.g. "1", "k", "lol"). Update optimistic key to latest sent ID. + this._optimisticKeys.set(contentKey, msg.id); + } else if (msg.scraped) { + return; // always drop scraped duplicates + } else { + // Real message (IRC echo) matching an optimistic message → upgrade in-place + const optId = this._optimisticKeys.get(contentKey); + if (optId) { + this._upgradeOptimistic(optId, msg); + this._optimisticKeys.delete(contentKey); + } + return; + } + } else { + this._seenContentKeys.add(contentKey); + if (msg._optimistic) this._optimisticKeys.set(contentKey, msg.id); + } if (this._seenContentKeys.size > 2000) { const arr = [...this._seenContentKeys]; this._seenContentKeys = new Set(arr.slice(-1000)); @@ -2415,13 +2512,29 @@ class UnityChat { this.msgCount++; - // Sbírat usernames pro @mention autocomplete - if (msg.username) { - this._chatUsers.set(msg.username.toLowerCase(), { - name: msg.username, - platform: msg.platform, - color: msg.color - }); + // Sbírat usernames + barvy (platform:username → color mapping) + // Optimistic messages skip — their color may be wrong (from _platformColors fallback); + // the real IRC echo will set the correct color via _upgradeOptimistic + if (msg.username && !msg._optimistic) { + const colorKey = `${msg.platform}:${msg.username.toLowerCase()}`; + const plainKey = msg.username.toLowerCase(); + const entry = { name: msg.username, platform: msg.platform, color: msg.color }; + if (msg.color) { + const prev = this._chatUsers.get(colorKey); + this._chatUsers.set(colorKey, entry); + this._chatUsers.set(plainKey, entry); // for @autocomplete + if (!prev || prev.color !== msg.color) { + if (!this._userColorTimer) { + this._userColorTimer = setTimeout(() => { + this._userColorTimer = null; + chrome.storage.local.set({ uc_user_colors: Object.fromEntries(this._chatUsers) }).catch(() => {}); + }, 2000); + } + } + } else if (!this._chatUsers.has(colorKey)) { + this._chatUsers.set(colorKey, entry); + if (!this._chatUsers.has(plainKey)) this._chatUsers.set(plainKey, entry); + } } // Učení nativních emotes z příchozích zpráv @@ -2445,10 +2558,19 @@ class UnityChat { } msg._uc = true; // zachovat pro cache - // Track color from own messages (username detection is PING-only) + // Track color from own sent message echo + persist if (this._lastSentText && msg.message === this._lastSentText) { this._lastSentText = null; - if (msg.color) this._lastUserColor = msg.color; + if (msg.color && msg.platform) { + this._savePlatformColor(msg.platform, msg.color); + } + } + // Also track from username match + if (msg.color && msg.platform) { + const myName = this._platformUsernames[msg.platform]?.toLowerCase(); + if (myName && msg.username?.toLowerCase() === myName) { + this._savePlatformColor(msg.platform, msg.color); + } } } @@ -2459,10 +2581,14 @@ class UnityChat { // that may be null when rendering cached messages at startup) const myNick = myName ? this.nicknames.getNickname(msg.platform, this.config.username)?.toLowerCase() : null; const msgLower = msg.message?.toLowerCase() || ''; + const replyTarget = msg.replyTo?.username?.toLowerCase(); const isMentioned = myName && ( msgLower.includes(`@${myName}`) || (myNick && msgLower.includes(`@${myNick}`)) || - msg.replyTo?.username?.toLowerCase() === myName + replyTarget === myName || + (myNick && replyTarget === myNick) || + // Also match platform-specific username + (this._platformUsernames[msg.platform] && replyTarget === this._platformUsernames[msg.platform]?.toLowerCase()) ); const el = document.createElement('div'); @@ -2552,7 +2678,8 @@ class UnityChat { const un = document.createElement('span'); un.className = 'un'; const ucProfile = isUC ? this.nicknames.get(msg.platform, msg.username) : null; - un.style.color = ucProfile?.color || msg.color; + // Color priority: UC custom → chatUsers map (platform:username) → msg.color fallback + un.style.color = ucProfile?.color || this._chatUsers.get(`${msg.platform}:${msg.username?.toLowerCase()}`)?.color || msg.color; un.textContent = ucProfile?.nickname || msg.username; if (ucProfile?.nickname) un.title = msg.username; // tooltip shows real username un.dataset.platform = msg.platform; @@ -2607,7 +2734,7 @@ class UnityChat { replyBtn.innerHTML = '↩'; // ↩ replyBtn.addEventListener('click', (e) => { e.stopPropagation(); - this._setReply(msg.platform, msg.username, msg.id); + this._setReply(msg.platform, msg.username, msg.id, msg.message); }); actions.appendChild(replyBtn); el.appendChild(actions); @@ -2654,9 +2781,9 @@ class UnityChat { const [r, g, b] = m.color.match(/\d+/g); m.color = '#' + [r, g, b].map(c => (+c).toString(16).padStart(2, '0')).join(''); } - // Slim replyTo: drop message text, keep id + username + // Slim replyTo: keep id, username and message if (m.replyTo && typeof m.replyTo === 'object') { - m.replyTo = { id: m.replyTo.id, username: m.replyTo.username }; + m.replyTo = { id: m.replyTo.id, username: m.replyTo.username, message: m.replyTo.message || null }; } return m; } @@ -2682,6 +2809,63 @@ class UnityChat { chrome.storage.local.set({ [this._cacheKey]: this._msgCache }).catch(() => {}); } + // Upgrade an optimistic message to a real one (IRC echo arrived) + _upgradeOptimistic(optId, realMsg) { + // Update DOM element in-place + const el = this.chatEl.querySelector(`[data-msg-id="${CSS.escape(optId)}"]`); + if (el && realMsg.id) el.dataset.msgId = realMsg.id; + if (realMsg.id) this._seenMsgIds.add(realMsg.id); + + // Update username color + if (el && realMsg.color) { + const un = el.querySelector('.un'); + if (un) un.style.color = realMsg.color; + } + + // Add badges if the optimistic message didn't have them + if (el && realMsg.badgesRaw && !el.querySelector('.bdg')) { + const un = el.querySelector('.un'); + const bdg = document.createElement('span'); + bdg.className = 'bdg'; + for (const badge of realMsg.badgesRaw.split(',')) { + if (!badge) continue; + const url = this._twitchBadges[badge]; + if (url) { + const img = document.createElement('img'); + img.className = 'bdg-img'; + img.src = url; + img.alt = badge.split('/')[0]; + img.title = badge.split('/')[0]; + bdg.appendChild(img); + } + } + if (bdg.children.length && un) el.insertBefore(bdg, un); + } + + // Update cache entry: replace optimistic data with real data + const cacheIdx = this._msgCache.findIndex(m => m.id === optId); + if (cacheIdx !== -1) { + const cached = this._msgCache[cacheIdx]; + if (realMsg.id) cached.id = realMsg.id; + if (realMsg.color) cached.color = realMsg.color; + if (realMsg.badgesRaw) cached.badgesRaw = realMsg.badgesRaw; + if (realMsg.twitchEmotes) cached.twitchEmotes = realMsg.twitchEmotes; + delete cached._optimistic; + chrome.storage.local.set({ [this._cacheKey]: this._msgCache }).catch(() => {}); + } + + // Update _chatUsers with the correct color from the real message + if (realMsg.color && realMsg.username) { + const colorKey = `${realMsg.platform}:${realMsg.username.toLowerCase()}`; + this._chatUsers.set(colorKey, { name: realMsg.username, platform: realMsg.platform, color: realMsg.color }); + this._chatUsers.set(realMsg.username.toLowerCase(), { name: realMsg.username, platform: realMsg.platform, color: realMsg.color }); + const myName = (this._platformUsernames[realMsg.platform] || this.config.username || '').toLowerCase(); + if (myName && realMsg.username.toLowerCase() === myName) { + this._savePlatformColor(realMsg.platform, realMsg.color); + } + } + } + async _loadCachedMessages() { try { const data = await chrome.storage.local.get(this._cacheKey); diff --git a/landing/unitychat/dev/index.html b/landing/unitychat/dev/index.html index 3d9c48b..81ab0a4 100644 --- a/landing/unitychat/dev/index.html +++ b/landing/unitychat/dev/index.html @@ -88,7 +88,7 @@

UnityChat

v... · branch: dev
...
- Stáhnout DEV ZIP + Stáhnout DEV ZIP