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

lmata 2026-04-04 04:24 trunk
Commit f5142030cbdeadb4ef844391ea9219174a7763fe90388fb58907793323b996ed
1 file changed +58 -1
--- internal/api/ui/index.html
+++ internal/api/ui/index.html
@@ -170,10 +170,14 @@
170170
.login-brand h1 { font-size:22px; color:#58a6ff; letter-spacing:.05em; }
171171
.login-brand p { color:#8b949e; font-size:13px; margin-top:6px; }
172172
/* unread badge on chat tab */
173173
.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; }
174174
.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; }
175179
.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; }
176180
177181
/* channels tab */
178182
.chan-card { display:flex; align-items:center; gap:12px; padding:12px 16px; border-bottom:1px solid #21262d; }
179183
.chan-card:last-child { border-bottom:none; }
@@ -486,10 +490,11 @@
486490
<span style="font-size:11px;color:#8b949e;margin-right:6px">chatting as</span>
487491
<select id="chat-identity" style="width:140px;padding:3px 6px;font-size:12px" onchange="saveChatIdentity()">
488492
<option value="">— pick a user —</option>
489493
</select>
490494
<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>
491496
<span class="stream-badge" id="chat-stream-status" style="margin-left:8px"></span>
492497
</div>
493498
<div class="chat-msgs" id="chat-msgs">
494499
<div class="empty" id="chat-placeholder">join a channel to start chatting</div>
495500
</div>
@@ -1858,11 +1863,25 @@
18581863
const row = document.createElement('div');
18591864
row.className = 'msg-row' + (grouped ? ' msg-grouped' : '');
18601865
row.innerHTML =
18611866
`<span class="msg-time" title="${esc(new Date(msg.at).toLocaleString())}">${esc(timeStr)}</span>` +
18621867
`<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
+
18641883
area.appendChild(row);
18651884
18661885
// Unread badge when chat tab not active
18671886
if (!isHistory && !document.getElementById('tab-chat').classList.contains('active')) {
18681887
_chatUnread++;
@@ -1984,10 +2003,48 @@
19842003
});
19852004
})();
19862005
19872006
// --- sidebar collapse toggles ---
19882007
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
+}
19892046
19902047
function toggleSidebar(side) {
19912048
if (side === 'left') {
19922049
const el = document.getElementById('chat-sidebar-left');
19932050
const btn = document.getElementById('sidebar-left-toggle');
19942051
--- 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

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button