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
|
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():
|
||||||
|
|||||||
Reference in New Issue
Block a user