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:
62
chat.py
62
chat.py
@@ -25,7 +25,7 @@ import html
|
||||
import urllib.parse
|
||||
|
||||
PORT = 9999
|
||||
VERSION = "7"
|
||||
VERSION = "8"
|
||||
TUNNEL_TARGET = ("185.208.174.152", 22)
|
||||
MAX_FILE_SIZE = 1 * 1024 * 1024 # 1 MB per file
|
||||
MAX_TOTAL_STORAGE = 50 * 1024 * 1024 # 50 MB total
|
||||
@@ -248,9 +248,48 @@ $file.onchange = function() {
|
||||
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
|
||||
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…'}); };
|
||||
</script>
|
||||
</body>
|
||||
@@ -667,6 +706,22 @@ class ChatClient:
|
||||
self.writer: asyncio.StreamWriter | None = None
|
||||
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):
|
||||
t = time.strftime("%H:%M", time.localtime(msg["ts"]))
|
||||
cp = user_color_pair(msg["user"], self.name)
|
||||
@@ -681,6 +736,7 @@ class ChatClient:
|
||||
self.messages.append((f" {t} {msg['user']}: {lines[0]}", cp))
|
||||
for extra in lines[1:]:
|
||||
self.messages.append((f" {extra}", cp))
|
||||
self.notify(msg)
|
||||
|
||||
async def recv_loop(self):
|
||||
while self.running:
|
||||
@@ -768,6 +824,8 @@ class ChatClient:
|
||||
await asyncio.sleep(0.05)
|
||||
continue
|
||||
|
||||
self.reset_title()
|
||||
|
||||
if isinstance(ch, str):
|
||||
if ch == "\n":
|
||||
if self.input_buf.strip():
|
||||
|
||||
Reference in New Issue
Block a user