diff --git a/chat.py b/chat.py index fb32f6c..debca90 100644 --- a/chat.py +++ b/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…'}); }; @@ -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():