ScuttleBot
ui: chat message highlighting — nick mentions, danger keywords, system messages - Nick mentions: blue left border when someone says your username - Danger keywords: red left border for rm -rf, git reset, force push, etc. - System messages: dimmed italic for joins/parts/reconnects - Keyword highlighting: matching words get an orange background inline - Configurable: click ✦ in chat topbar to edit highlight keywords - Defaults stored in localStorage, editable per user
Commit
f5142030cbdeadb4ef844391ea9219174a7763fe90388fb58907793323b996ed
Parent
008a0fa989f6f5e…
1 file changed
+58
-1
+58
-1
| --- internal/api/ui/index.html | ||
| +++ internal/api/ui/index.html | ||
| @@ -170,10 +170,14 @@ | ||
| 170 | 170 | .login-brand h1 { font-size:22px; color:#58a6ff; letter-spacing:.05em; } |
| 171 | 171 | .login-brand p { color:#8b949e; font-size:13px; margin-top:6px; } |
| 172 | 172 | /* unread badge on chat tab */ |
| 173 | 173 | .nav-tab[data-unread]::after { content:attr(data-unread); background:#f85149; color:#fff; border-radius:999px; padding:1px 5px; font-size:10px; margin-left:5px; vertical-align:middle; } |
| 174 | 174 | .msg-text { color:#e6edf3; word-break:break-word; } |
| 175 | +.msg-row.hl-mention { background:#1f6feb18; border-left:2px solid #58a6ff; padding-left:6px; } | |
| 176 | +.msg-row.hl-danger { background:#f8514918; border-left:2px solid #f85149; padding-left:6px; } | |
| 177 | +.msg-row.hl-system { opacity:0.6; font-style:italic; } | |
| 178 | +.msg-text .hl-word { background:#f0883e33; border-radius:2px; padding:0 2px; } | |
| 175 | 179 | .chat-input { padding:9px 13px; padding-bottom:calc(9px + env(safe-area-inset-bottom, 0px)); border-top:1px solid #30363d; display:flex; gap:7px; flex-shrink:0; background:#161b22; } |
| 176 | 180 | |
| 177 | 181 | /* channels tab */ |
| 178 | 182 | .chan-card { display:flex; align-items:center; gap:12px; padding:12px 16px; border-bottom:1px solid #21262d; } |
| 179 | 183 | .chan-card:last-child { border-bottom:none; } |
| @@ -486,10 +490,11 @@ | ||
| 486 | 490 | <span style="font-size:11px;color:#8b949e;margin-right:6px">chatting as</span> |
| 487 | 491 | <select id="chat-identity" style="width:140px;padding:3px 6px;font-size:12px" onchange="saveChatIdentity()"> |
| 488 | 492 | <option value="">— pick a user —</option> |
| 489 | 493 | </select> |
| 490 | 494 | <button class="sm" id="chat-layout-toggle" onclick="toggleChatLayout()" title="toggle compact/columnar" style="font-size:11px;padding:2px 6px">☰</button> |
| 495 | + <button class="sm" onclick="promptHighlightWords()" title="configure highlight keywords" style="font-size:11px;padding:2px 6px">✦</button> | |
| 491 | 496 | <span class="stream-badge" id="chat-stream-status" style="margin-left:8px"></span> |
| 492 | 497 | </div> |
| 493 | 498 | <div class="chat-msgs" id="chat-msgs"> |
| 494 | 499 | <div class="empty" id="chat-placeholder">join a channel to start chatting</div> |
| 495 | 500 | </div> |
| @@ -1858,11 +1863,25 @@ | ||
| 1858 | 1863 | const row = document.createElement('div'); |
| 1859 | 1864 | row.className = 'msg-row' + (grouped ? ' msg-grouped' : ''); |
| 1860 | 1865 | row.innerHTML = |
| 1861 | 1866 | `<span class="msg-time" title="${esc(new Date(msg.at).toLocaleString())}">${esc(timeStr)}</span>` + |
| 1862 | 1867 | `<span class="msg-nick" style="color:${color}">[${esc(displayNick)}]:</span>` + |
| 1863 | - `<span class="msg-text">${esc(displayText)}</span>`; | |
| 1868 | + `<span class="msg-text">${highlightText(esc(displayText))}</span>`; | |
| 1869 | + | |
| 1870 | + // Apply row-level highlights. | |
| 1871 | + const myNick = localStorage.getItem('sb_username') || ''; | |
| 1872 | + const lower = displayText.toLowerCase(); | |
| 1873 | + if (myNick && lower.includes(myNick.toLowerCase())) { | |
| 1874 | + row.classList.add('hl-mention'); | |
| 1875 | + } else if (getDangerWords().some(w => lower.includes(w))) { | |
| 1876 | + row.classList.add('hl-danger'); | |
| 1877 | + } | |
| 1878 | + // System messages (joins, parts, reconnects). | |
| 1879 | + if (/\b(online|offline|reconnected|joined|parted)\b/i.test(displayText) && !displayText.includes(': ')) { | |
| 1880 | + row.classList.add('hl-system'); | |
| 1881 | + } | |
| 1882 | + | |
| 1864 | 1883 | area.appendChild(row); |
| 1865 | 1884 | |
| 1866 | 1885 | // Unread badge when chat tab not active |
| 1867 | 1886 | if (!isHistory && !document.getElementById('tab-chat').classList.contains('active')) { |
| 1868 | 1887 | _chatUnread++; |
| @@ -1984,10 +2003,48 @@ | ||
| 1984 | 2003 | }); |
| 1985 | 2004 | })(); |
| 1986 | 2005 | |
| 1987 | 2006 | // --- sidebar collapse toggles --- |
| 1988 | 2007 | function isMobile() { return window.matchMedia('(max-width: 600px)').matches; } |
| 2008 | + | |
| 2009 | +// --- chat highlights --- | |
| 2010 | +const DEFAULT_DANGER_WORDS = ['rm -rf','git reset','git push --force','force push','drop table','delete from','git checkout .','git restore .','--no-verify']; | |
| 2011 | + | |
| 2012 | +function promptHighlightWords() { | |
| 2013 | + const current = getDangerWords().join(', '); | |
| 2014 | + const input = prompt('Highlight keywords (comma-separated).\nMessages containing these words get a red border.\n\nCurrent:', current); | |
| 2015 | + if (input === null) return; // cancelled | |
| 2016 | + const words = input.split(',').map(s => s.trim()).filter(Boolean); | |
| 2017 | + localStorage.setItem('sb_highlight_words', JSON.stringify(words)); | |
| 2018 | +} | |
| 2019 | + | |
| 2020 | +function getDangerWords() { | |
| 2021 | + try { | |
| 2022 | + const custom = JSON.parse(localStorage.getItem('sb_highlight_words') || 'null'); | |
| 2023 | + if (Array.isArray(custom)) return custom.map(w => w.toLowerCase()); | |
| 2024 | + } catch(e) {} | |
| 2025 | + return DEFAULT_DANGER_WORDS; | |
| 2026 | +} | |
| 2027 | + | |
| 2028 | +function getHighlightWords() { | |
| 2029 | + const words = []; | |
| 2030 | + const myNick = localStorage.getItem('sb_username') || ''; | |
| 2031 | + if (myNick) words.push(myNick); | |
| 2032 | + words.push(...getDangerWords()); | |
| 2033 | + return words; | |
| 2034 | +} | |
| 2035 | + | |
| 2036 | +function highlightText(escaped) { | |
| 2037 | + const words = getHighlightWords(); | |
| 2038 | + if (words.length === 0) return escaped; | |
| 2039 | + // Build regex from all highlight words, longest first to avoid partial matches. | |
| 2040 | + const sorted = words.slice().sort((a, b) => b.length - a.length); | |
| 2041 | + const pattern = sorted.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'); | |
| 2042 | + if (!pattern) return escaped; | |
| 2043 | + const re = new RegExp('(' + pattern + ')', 'gi'); | |
| 2044 | + return escaped.replace(re, '<span class="hl-word">$1</span>'); | |
| 2045 | +} | |
| 1989 | 2046 | |
| 1990 | 2047 | function toggleSidebar(side) { |
| 1991 | 2048 | if (side === 'left') { |
| 1992 | 2049 | const el = document.getElementById('chat-sidebar-left'); |
| 1993 | 2050 | const btn = document.getElementById('sidebar-left-toggle'); |
| 1994 | 2051 |
| --- internal/api/ui/index.html | |
| +++ internal/api/ui/index.html | |
| @@ -170,10 +170,14 @@ | |
| 170 | .login-brand h1 { font-size:22px; color:#58a6ff; letter-spacing:.05em; } |
| 171 | .login-brand p { color:#8b949e; font-size:13px; margin-top:6px; } |
| 172 | /* unread badge on chat tab */ |
| 173 | .nav-tab[data-unread]::after { content:attr(data-unread); background:#f85149; color:#fff; border-radius:999px; padding:1px 5px; font-size:10px; margin-left:5px; vertical-align:middle; } |
| 174 | .msg-text { color:#e6edf3; word-break:break-word; } |
| 175 | .chat-input { padding:9px 13px; padding-bottom:calc(9px + env(safe-area-inset-bottom, 0px)); border-top:1px solid #30363d; display:flex; gap:7px; flex-shrink:0; background:#161b22; } |
| 176 | |
| 177 | /* channels tab */ |
| 178 | .chan-card { display:flex; align-items:center; gap:12px; padding:12px 16px; border-bottom:1px solid #21262d; } |
| 179 | .chan-card:last-child { border-bottom:none; } |
| @@ -486,10 +490,11 @@ | |
| 486 | <span style="font-size:11px;color:#8b949e;margin-right:6px">chatting as</span> |
| 487 | <select id="chat-identity" style="width:140px;padding:3px 6px;font-size:12px" onchange="saveChatIdentity()"> |
| 488 | <option value="">— pick a user —</option> |
| 489 | </select> |
| 490 | <button class="sm" id="chat-layout-toggle" onclick="toggleChatLayout()" title="toggle compact/columnar" style="font-size:11px;padding:2px 6px">☰</button> |
| 491 | <span class="stream-badge" id="chat-stream-status" style="margin-left:8px"></span> |
| 492 | </div> |
| 493 | <div class="chat-msgs" id="chat-msgs"> |
| 494 | <div class="empty" id="chat-placeholder">join a channel to start chatting</div> |
| 495 | </div> |
| @@ -1858,11 +1863,25 @@ | |
| 1858 | const row = document.createElement('div'); |
| 1859 | row.className = 'msg-row' + (grouped ? ' msg-grouped' : ''); |
| 1860 | row.innerHTML = |
| 1861 | `<span class="msg-time" title="${esc(new Date(msg.at).toLocaleString())}">${esc(timeStr)}</span>` + |
| 1862 | `<span class="msg-nick" style="color:${color}">[${esc(displayNick)}]:</span>` + |
| 1863 | `<span class="msg-text">${esc(displayText)}</span>`; |
| 1864 | area.appendChild(row); |
| 1865 | |
| 1866 | // Unread badge when chat tab not active |
| 1867 | if (!isHistory && !document.getElementById('tab-chat').classList.contains('active')) { |
| 1868 | _chatUnread++; |
| @@ -1984,10 +2003,48 @@ | |
| 1984 | }); |
| 1985 | })(); |
| 1986 | |
| 1987 | // --- sidebar collapse toggles --- |
| 1988 | function isMobile() { return window.matchMedia('(max-width: 600px)').matches; } |
| 1989 | |
| 1990 | function toggleSidebar(side) { |
| 1991 | if (side === 'left') { |
| 1992 | const el = document.getElementById('chat-sidebar-left'); |
| 1993 | const btn = document.getElementById('sidebar-left-toggle'); |
| 1994 |
| --- internal/api/ui/index.html | |
| +++ internal/api/ui/index.html | |
| @@ -170,10 +170,14 @@ | |
| 170 | .login-brand h1 { font-size:22px; color:#58a6ff; letter-spacing:.05em; } |
| 171 | .login-brand p { color:#8b949e; font-size:13px; margin-top:6px; } |
| 172 | /* unread badge on chat tab */ |
| 173 | .nav-tab[data-unread]::after { content:attr(data-unread); background:#f85149; color:#fff; border-radius:999px; padding:1px 5px; font-size:10px; margin-left:5px; vertical-align:middle; } |
| 174 | .msg-text { color:#e6edf3; word-break:break-word; } |
| 175 | .msg-row.hl-mention { background:#1f6feb18; border-left:2px solid #58a6ff; padding-left:6px; } |
| 176 | .msg-row.hl-danger { background:#f8514918; border-left:2px solid #f85149; padding-left:6px; } |
| 177 | .msg-row.hl-system { opacity:0.6; font-style:italic; } |
| 178 | .msg-text .hl-word { background:#f0883e33; border-radius:2px; padding:0 2px; } |
| 179 | .chat-input { padding:9px 13px; padding-bottom:calc(9px + env(safe-area-inset-bottom, 0px)); border-top:1px solid #30363d; display:flex; gap:7px; flex-shrink:0; background:#161b22; } |
| 180 | |
| 181 | /* channels tab */ |
| 182 | .chan-card { display:flex; align-items:center; gap:12px; padding:12px 16px; border-bottom:1px solid #21262d; } |
| 183 | .chan-card:last-child { border-bottom:none; } |
| @@ -486,10 +490,11 @@ | |
| 490 | <span style="font-size:11px;color:#8b949e;margin-right:6px">chatting as</span> |
| 491 | <select id="chat-identity" style="width:140px;padding:3px 6px;font-size:12px" onchange="saveChatIdentity()"> |
| 492 | <option value="">— pick a user —</option> |
| 493 | </select> |
| 494 | <button class="sm" id="chat-layout-toggle" onclick="toggleChatLayout()" title="toggle compact/columnar" style="font-size:11px;padding:2px 6px">☰</button> |
| 495 | <button class="sm" onclick="promptHighlightWords()" title="configure highlight keywords" style="font-size:11px;padding:2px 6px">✦</button> |
| 496 | <span class="stream-badge" id="chat-stream-status" style="margin-left:8px"></span> |
| 497 | </div> |
| 498 | <div class="chat-msgs" id="chat-msgs"> |
| 499 | <div class="empty" id="chat-placeholder">join a channel to start chatting</div> |
| 500 | </div> |
| @@ -1858,11 +1863,25 @@ | |
| 1863 | const row = document.createElement('div'); |
| 1864 | row.className = 'msg-row' + (grouped ? ' msg-grouped' : ''); |
| 1865 | row.innerHTML = |
| 1866 | `<span class="msg-time" title="${esc(new Date(msg.at).toLocaleString())}">${esc(timeStr)}</span>` + |
| 1867 | `<span class="msg-nick" style="color:${color}">[${esc(displayNick)}]:</span>` + |
| 1868 | `<span class="msg-text">${highlightText(esc(displayText))}</span>`; |
| 1869 | |
| 1870 | // Apply row-level highlights. |
| 1871 | const myNick = localStorage.getItem('sb_username') || ''; |
| 1872 | const lower = displayText.toLowerCase(); |
| 1873 | if (myNick && lower.includes(myNick.toLowerCase())) { |
| 1874 | row.classList.add('hl-mention'); |
| 1875 | } else if (getDangerWords().some(w => lower.includes(w))) { |
| 1876 | row.classList.add('hl-danger'); |
| 1877 | } |
| 1878 | // System messages (joins, parts, reconnects). |
| 1879 | if (/\b(online|offline|reconnected|joined|parted)\b/i.test(displayText) && !displayText.includes(': ')) { |
| 1880 | row.classList.add('hl-system'); |
| 1881 | } |
| 1882 | |
| 1883 | area.appendChild(row); |
| 1884 | |
| 1885 | // Unread badge when chat tab not active |
| 1886 | if (!isHistory && !document.getElementById('tab-chat').classList.contains('active')) { |
| 1887 | _chatUnread++; |
| @@ -1984,10 +2003,48 @@ | |
| 2003 | }); |
| 2004 | })(); |
| 2005 | |
| 2006 | // --- sidebar collapse toggles --- |
| 2007 | function isMobile() { return window.matchMedia('(max-width: 600px)').matches; } |
| 2008 | |
| 2009 | // --- chat highlights --- |
| 2010 | const DEFAULT_DANGER_WORDS = ['rm -rf','git reset','git push --force','force push','drop table','delete from','git checkout .','git restore .','--no-verify']; |
| 2011 | |
| 2012 | function promptHighlightWords() { |
| 2013 | const current = getDangerWords().join(', '); |
| 2014 | const input = prompt('Highlight keywords (comma-separated).\nMessages containing these words get a red border.\n\nCurrent:', current); |
| 2015 | if (input === null) return; // cancelled |
| 2016 | const words = input.split(',').map(s => s.trim()).filter(Boolean); |
| 2017 | localStorage.setItem('sb_highlight_words', JSON.stringify(words)); |
| 2018 | } |
| 2019 | |
| 2020 | function getDangerWords() { |
| 2021 | try { |
| 2022 | const custom = JSON.parse(localStorage.getItem('sb_highlight_words') || 'null'); |
| 2023 | if (Array.isArray(custom)) return custom.map(w => w.toLowerCase()); |
| 2024 | } catch(e) {} |
| 2025 | return DEFAULT_DANGER_WORDS; |
| 2026 | } |
| 2027 | |
| 2028 | function getHighlightWords() { |
| 2029 | const words = []; |
| 2030 | const myNick = localStorage.getItem('sb_username') || ''; |
| 2031 | if (myNick) words.push(myNick); |
| 2032 | words.push(...getDangerWords()); |
| 2033 | return words; |
| 2034 | } |
| 2035 | |
| 2036 | function highlightText(escaped) { |
| 2037 | const words = getHighlightWords(); |
| 2038 | if (words.length === 0) return escaped; |
| 2039 | // Build regex from all highlight words, longest first to avoid partial matches. |
| 2040 | const sorted = words.slice().sort((a, b) => b.length - a.length); |
| 2041 | const pattern = sorted.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'); |
| 2042 | if (!pattern) return escaped; |
| 2043 | const re = new RegExp('(' + pattern + ')', 'gi'); |
| 2044 | return escaped.replace(re, '<span class="hl-word">$1</span>'); |
| 2045 | } |
| 2046 | |
| 2047 | function toggleSidebar(side) { |
| 2048 | if (side === 'left') { |
| 2049 | const el = document.getElementById('chat-sidebar-left'); |
| 2050 | const btn = document.getElementById('sidebar-left-toggle'); |
| 2051 |