v8: notifications - browser push notifications and terminal bell/title

Web UI:
- Requests browser notification permission on load
- Shows desktop notification for messages from others when tab unfocused
- Tab title shows unread count: "(3) Chat"
- Resets on focus

Terminal client:
- Bell (\a) on messages from others
- Terminal title updates to show sender and preview
- Title resets when user types

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-26 14:51:26 +04:00
parent d55b65db1f
commit 6aa2717560

62
chat.py
View File

@@ -25,7 +25,7 @@ import html
import urllib.parse import urllib.parse
PORT = 9999 PORT = 9999
VERSION = "7" VERSION = "8"
TUNNEL_TARGET = ("185.208.174.152", 22) TUNNEL_TARGET = ("185.208.174.152", 22)
MAX_FILE_SIZE = 1 * 1024 * 1024 # 1 MB per file MAX_FILE_SIZE = 1 * 1024 * 1024 # 1 MB per file
MAX_TOTAL_STORAGE = 50 * 1024 * 1024 # 50 MB total MAX_TOTAL_STORAGE = 50 * 1024 * 1024 # 50 MB total
@@ -248,9 +248,48 @@ $file.onchange = function() {
this.value = ''; this.value = '';
}; };
// Notifications
let notifEnabled = false;
if ('Notification' in window) {
if (Notification.permission === 'granted') { notifEnabled = true; }
else if (Notification.permission !== 'denied') {
Notification.requestPermission().then(p => { notifEnabled = (p === 'granted'); });
}
}
let unreadCount = 0;
const baseTitle = document.title;
function notify(data) {
// Only notify when tab is not focused and it's not our own message
if (document.hasFocus()) return;
if (data.user === $name.value.trim()) return;
if (data.user === '***') return;
unreadCount++;
document.title = '(' + unreadCount + ') ' + baseTitle;
if (notifEnabled) {
const body = data.file_id
? data.user + ' shared a file: ' + data.filename
: data.user + ': ' + data.text.substring(0, 100);
const n = new Notification('Chat', { body: body, tag: 'chat-msg' });
setTimeout(() => n.close(), 5000);
}
}
window.addEventListener('focus', function() {
unreadCount = 0;
document.title = baseTitle;
});
// SSE // SSE
const es = new EventSource('/chat/events'); const es = new EventSource('/chat/events');
es.onmessage = function(e) { addMsg(JSON.parse(e.data)); }; es.onmessage = function(e) {
const data = JSON.parse(e.data);
addMsg(data);
notify(data);
};
es.onerror = function() { addMsg({ts: Date.now()/1000, user: '***', text: 'Connection lost. Retrying…'}); }; es.onerror = function() { addMsg({ts: Date.now()/1000, user: '***', text: 'Connection lost. Retrying…'}); };
</script> </script>
</body> </body>
@@ -667,6 +706,22 @@ class ChatClient:
self.writer: asyncio.StreamWriter | None = None self.writer: asyncio.StreamWriter | None = None
self.running = True self.running = True
def notify(self, msg: dict):
"""Send terminal bell + update title for messages from others."""
if msg["user"] == self.name or msg["user"] == "***":
return
# Terminal bell
sys.stdout.write("\a")
sys.stdout.flush()
# OSC title update (works in most terminals: iTerm2, Terminal.app, etc.)
preview = msg["text"][:50] if not msg.get("file_id") else f"shared {msg['filename']}"
sys.stdout.write(f"\033]0;[NEW] {msg['user']}: {preview}\007")
sys.stdout.flush()
def reset_title(self):
sys.stdout.write(f"\033]0;Chat - {self.name}\007")
sys.stdout.flush()
def add_message(self, msg: dict): def add_message(self, msg: dict):
t = time.strftime("%H:%M", time.localtime(msg["ts"])) t = time.strftime("%H:%M", time.localtime(msg["ts"]))
cp = user_color_pair(msg["user"], self.name) cp = user_color_pair(msg["user"], self.name)
@@ -681,6 +736,7 @@ class ChatClient:
self.messages.append((f" {t} {msg['user']}: {lines[0]}", cp)) self.messages.append((f" {t} {msg['user']}: {lines[0]}", cp))
for extra in lines[1:]: for extra in lines[1:]:
self.messages.append((f" {extra}", cp)) self.messages.append((f" {extra}", cp))
self.notify(msg)
async def recv_loop(self): async def recv_loop(self):
while self.running: while self.running:
@@ -768,6 +824,8 @@ class ChatClient:
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
continue continue
self.reset_title()
if isinstance(ch, str): if isinstance(ch, str):
if ch == "\n": if ch == "\n":
if self.input_buf.strip(): if self.input_buf.strip():