after hours of work, attachments work!
This commit is contained in:
180
desktop.py
180
desktop.py
@@ -1,21 +1,22 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Desktop GUI wrapper for chatter.py using Tkinter.
|
||||
- Runs the Flask chatter backend locally (threaded)
|
||||
- Simple chat interface: login/register + message view/send
|
||||
- Polls messages every 1.5s
|
||||
- Popup + camera notifications (using plyer + OpenCV)
|
||||
Supports text messages and attachments (files) with clickable filenames.
|
||||
Polls server every 1.5s for updates.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox, filedialog, scrolledtext
|
||||
import requests, os, queue, time, cv2
|
||||
from tkinter import filedialog, scrolledtext, messagebox
|
||||
import requests, os, time, webbrowser
|
||||
from datetime import datetime
|
||||
from plyer import notification
|
||||
import cv2
|
||||
from PIL import Image, ImageTk
|
||||
from io import BytesIO
|
||||
|
||||
SERVER = "https://chatter.wholeworldcoding.com"
|
||||
SESSION = requests.Session()
|
||||
MSG_QUEUE = queue.Queue()
|
||||
LAST_MSG_IDS = set()
|
||||
|
||||
|
||||
@@ -26,16 +27,11 @@ class ChatApp:
|
||||
root.geometry("800x600")
|
||||
root.minsize(500, 400)
|
||||
|
||||
# --- Default settings ---
|
||||
self.settings = {
|
||||
"popup_notification": True, # Show system tray notifications
|
||||
"camera_notification": False, # Blink webcam LED
|
||||
"popup_notification": True,
|
||||
"camera_notification": False,
|
||||
}
|
||||
|
||||
# Make window scalable
|
||||
root.rowconfigure(0, weight=1)
|
||||
root.columnconfigure(0, weight=1)
|
||||
|
||||
self.username = None
|
||||
self.build_login_screen()
|
||||
|
||||
@@ -45,24 +41,21 @@ class ChatApp:
|
||||
w.destroy()
|
||||
|
||||
frame = tk.Frame(self.root)
|
||||
frame.grid(row=0, column=0, sticky="nsew")
|
||||
frame.rowconfigure(0, weight=1)
|
||||
frame.columnconfigure(0, weight=1)
|
||||
frame.pack(expand=True)
|
||||
|
||||
inner = tk.Frame(frame)
|
||||
inner.place(relx=0.5, rely=0.5, anchor="center")
|
||||
tk.Label(frame, text="chatter", font=("Courier", 22, "bold")).pack(pady=10)
|
||||
tk.Label(frame, text="Sign up or login to continue.", font=("Courier", 11)).pack(pady=5)
|
||||
|
||||
tk.Label(inner, text="chatter", font=("Courier", 22, "bold")).pack(pady=10)
|
||||
tk.Label(inner, text="Sign up or login to continue.", font=("Courier", 11, "bold")).pack(pady=5)
|
||||
|
||||
tk.Label(inner, text="Username").pack()
|
||||
self.entry_user = tk.Entry(inner, width=30)
|
||||
tk.Label(frame, text="Username").pack()
|
||||
self.entry_user = tk.Entry(frame, width=30)
|
||||
self.entry_user.pack(pady=3)
|
||||
tk.Label(inner, text="Password").pack()
|
||||
self.entry_pass = tk.Entry(inner, show="*", width=30)
|
||||
|
||||
tk.Label(frame, text="Password").pack()
|
||||
self.entry_pass = tk.Entry(frame, show="*", width=30)
|
||||
self.entry_pass.pack(pady=3)
|
||||
tk.Button(inner, text="Login", command=self.login, width=15).pack(pady=6)
|
||||
tk.Button(inner, text="Register", command=self.register, width=15).pack(pady=2)
|
||||
|
||||
tk.Button(frame, text="Login", command=self.login, width=15).pack(pady=6)
|
||||
tk.Button(frame, text="Register", command=self.register, width=15).pack(pady=2)
|
||||
|
||||
def build_chat_screen(self):
|
||||
for w in self.root.winfo_children():
|
||||
@@ -74,7 +67,8 @@ class ChatApp:
|
||||
header = tk.Frame(self.root)
|
||||
header.pack(fill=tk.X, pady=3)
|
||||
tk.Label(header, text=f"chatter - {self.username}", font=("Courier", 12)).pack(side=tk.LEFT, padx=10)
|
||||
tk.Button(header, text="Settings ⚙", command=self.open_settings).pack(side=tk.RIGHT, padx=10)
|
||||
tk.Button(header, text="Settings ⚙", command=self.open_settings).pack(side=tk.RIGHT, padx=5)
|
||||
tk.Button(header, text="Logout", command=self.logout).pack(side=tk.RIGHT, padx=5)
|
||||
|
||||
self.chat_box = scrolledtext.ScrolledText(self.root, wrap=tk.WORD, state='disabled')
|
||||
self.chat_box.pack(padx=10, pady=5, fill=tk.BOTH, expand=True)
|
||||
@@ -86,8 +80,7 @@ class ChatApp:
|
||||
self.msg_entry = tk.Entry(frame)
|
||||
self.msg_entry.grid(row=0, column=0, sticky="ew", padx=5)
|
||||
tk.Button(frame, text="Send", command=self.send_message).grid(row=0, column=1, padx=5)
|
||||
tk.Button(frame, text="File", command=self.send_file).grid(row=0, column=2, padx=5)
|
||||
tk.Button(self.root, text="Logout", command=self.logout).pack(pady=3)
|
||||
tk.Button(frame, text="📎 File", command=self.send_file).grid(row=0, column=2, padx=5)
|
||||
|
||||
self.poll_messages()
|
||||
|
||||
@@ -116,8 +109,8 @@ class ChatApp:
|
||||
# ---------- Auth ----------
|
||||
def login(self):
|
||||
data = {"username": self.entry_user.get(), "password": self.entry_pass.get()}
|
||||
r = SESSION.post(SERVER + "/login", data=data, allow_redirects=False)
|
||||
if r.status_code == 302:
|
||||
r = SESSION.post(SERVER + "/login", data=data)
|
||||
if r.url.endswith("/chat"):
|
||||
self.username = data["username"]
|
||||
self.build_chat_screen()
|
||||
else:
|
||||
@@ -125,8 +118,8 @@ class ChatApp:
|
||||
|
||||
def register(self):
|
||||
data = {"username": self.entry_user.get(), "password": self.entry_pass.get()}
|
||||
r = SESSION.post(SERVER + "/register", data=data, allow_redirects=False)
|
||||
if r.status_code == 302:
|
||||
r = SESSION.post(SERVER + "/register", data=data)
|
||||
if r.url.endswith("/login"):
|
||||
messagebox.showinfo("Success", "Account created! You can log in now.")
|
||||
else:
|
||||
messagebox.showerror("Error", "Registration failed")
|
||||
@@ -141,84 +134,141 @@ class ChatApp:
|
||||
text = self.msg_entry.get().strip()
|
||||
if not text:
|
||||
return
|
||||
SESSION.post(SERVER + "/api/messages", json={"text": text})
|
||||
try:
|
||||
r = SESSION.post(SERVER + "/api/messages", json={"text": text})
|
||||
if r.status_code == 201:
|
||||
self.msg_entry.delete(0, tk.END)
|
||||
self.poll_messages(force=True)
|
||||
except Exception as e:
|
||||
print(f"Send error: {e}")
|
||||
|
||||
def send_file(self):
|
||||
path = filedialog.askopenfilename()
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
files = {"file": (os.path.basename(path), f)}
|
||||
SESSION.post(SERVER + "/api/messages", files=files)
|
||||
self.poll_messages(force=True)
|
||||
except Exception as e:
|
||||
print(f"File send error: {e}")
|
||||
|
||||
def show_attachment_popup(self, file_name, username):
|
||||
popup = tk.Toplevel(self.root)
|
||||
popup.title(f"{username} sent a file")
|
||||
popup.geometry("400x400")
|
||||
|
||||
file_url = f"{SERVER}/uploads/{file_name}"
|
||||
preview_url = file_url.replace("/uploads/", "/preview/")
|
||||
|
||||
tk.Label(popup, wrap="400", text=f"{username} sent: {file_name}", font=("Courier", 12, "bold")).pack(pady=10)
|
||||
|
||||
# Attempt to preview image inline
|
||||
try:
|
||||
resp = requests.get(file_url)
|
||||
pil_img = Image.open(BytesIO(resp.content))
|
||||
pil_img.thumbnail((350, 350))
|
||||
tk_img = ImageTk.PhotoImage(pil_img)
|
||||
label_img = tk.Label(popup, image=tk_img)
|
||||
label_img.image = tk_img
|
||||
label_img.pack(pady=5)
|
||||
except Exception:
|
||||
tk.Label(popup, wrap="400", text="You can preview the file, or download it.").pack(pady=5)
|
||||
|
||||
btn_frame = tk.Frame(popup)
|
||||
btn_frame.pack(pady=10)
|
||||
|
||||
def download_file():
|
||||
save_path = filedialog.asksaveasfilename(initialfile=file_name)
|
||||
if save_path:
|
||||
with open(save_path, "wb") as f:
|
||||
f.write(requests.get(file_url).content)
|
||||
messagebox.showinfo("Downloaded", f"File saved to {save_path}")
|
||||
|
||||
tk.Button(btn_frame, text="Download", command=download_file).pack(side=tk.LEFT, padx=5)
|
||||
tk.Button(btn_frame, text="Preview", command=lambda: webbrowser.open(preview_url)).pack(side=tk.LEFT, padx=5)
|
||||
|
||||
def poll_messages(self, force=False):
|
||||
def _poll():
|
||||
try:
|
||||
r = SESSION.get(SERVER + "/api/messages", timeout=5)
|
||||
if r.status_code == 200:
|
||||
r = SESSION.get(SERVER + "/api/messages")
|
||||
if r.status_code != 200:
|
||||
return
|
||||
msgs = r.json()
|
||||
new_ids = {m["id"] for m in msgs}
|
||||
new = [m for m in msgs if m["id"] not in LAST_MSG_IDS]
|
||||
if new or force:
|
||||
new_msgs = [m for m in msgs if m["id"] not in LAST_MSG_IDS]
|
||||
|
||||
if new_msgs or force:
|
||||
self.chat_box.config(state='normal')
|
||||
self.chat_box.delete("1.0", tk.END)
|
||||
for m in msgs:
|
||||
line = f"[{m['username']}] {m['text']}\n"
|
||||
self.chat_box.insert(tk.END, line)
|
||||
ts = m.get("created_at") or time.time()
|
||||
try:
|
||||
ts_str = datetime.fromtimestamp(float(ts)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except:
|
||||
ts_str = str(ts)
|
||||
|
||||
if m.get("attachment"):
|
||||
attach_name = m["attachment"]
|
||||
tag_name = f"attach{m['id']}"
|
||||
|
||||
# Grey italic text for "sent a file"
|
||||
self.chat_box.insert(tk.END, f"[{ts_str}] {m['username']} sent a file: ", f"grey{m['id']}")
|
||||
self.chat_box.tag_config(f"grey{m['id']}", foreground="grey", font=("Courier", 10, "italic"))
|
||||
|
||||
# Blue clickable filename
|
||||
self.chat_box.insert(tk.END, f"{attach_name}\n", tag_name)
|
||||
self.chat_box.tag_config(tag_name, foreground="blue", underline=True)
|
||||
self.chat_box.tag_bind(tag_name, "<Button-1>",
|
||||
lambda e, fn=attach_name, un=m["username"]: self.show_attachment_popup(fn, un))
|
||||
else:
|
||||
text = m.get("text", "")
|
||||
self.chat_box.insert(tk.END, f"[{ts_str}] {m['username']}: {text}\n")
|
||||
|
||||
self.chat_box.config(state='disabled')
|
||||
self.chat_box.yview(tk.END)
|
||||
for m in new:
|
||||
|
||||
# Notify user about new messages
|
||||
for m in new_msgs:
|
||||
if m["username"] != self.username:
|
||||
self.notify_user(m["username"], m["text"])
|
||||
msg_preview = m.get("text") or m.get("attachment", "Attachment")
|
||||
self.notify_user(m["username"], msg_preview)
|
||||
|
||||
LAST_MSG_IDS.clear()
|
||||
LAST_MSG_IDS.update(new_ids)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"Poll error: {e}")
|
||||
|
||||
self.root.after(1500, self.poll_messages)
|
||||
threading.Thread(target=_poll, daemon=True).start()
|
||||
|
||||
# ---------- Notifications ----------
|
||||
def notify_user(self, sender, message):
|
||||
"""Triggers all enabled notifications."""
|
||||
if self.settings.get("popup_notification"):
|
||||
threading.Thread(
|
||||
target=lambda: self.show_system_notification(sender, message),
|
||||
daemon=True
|
||||
).start()
|
||||
threading.Thread(target=lambda: self.show_system_notification(sender, message), daemon=True).start()
|
||||
if self.settings.get("camera_notification"):
|
||||
threading.Thread(
|
||||
target=self.blink_camera_light,
|
||||
daemon=True
|
||||
).start()
|
||||
threading.Thread(target=self.blink_camera_light, daemon=True).start()
|
||||
|
||||
def show_system_notification(self, title, message):
|
||||
"""Show a real system notification using plyer."""
|
||||
try:
|
||||
notification.notify(
|
||||
title=f"New message from {title}",
|
||||
title=title,
|
||||
app_name="chatter",
|
||||
message=message[:200],
|
||||
timeout=5
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[Notification failed] {e}")
|
||||
print(f"Notification failed: {e}")
|
||||
|
||||
def blink_camera_light(self, blink_time=3):
|
||||
"""Turn camera on briefly to activate LED."""
|
||||
try:
|
||||
cap = cv2.VideoCapture(0)
|
||||
if cap.isOpened():
|
||||
ret, frame = cap.read() # read one frame so LED actually activates
|
||||
ret, frame = cap.read()
|
||||
if ret:
|
||||
time.sleep(blink_time)
|
||||
cap.release()
|
||||
else:
|
||||
print("[⚠] Could not access camera.")
|
||||
except Exception as e:
|
||||
print(f"[Camera blink failed] {e}")
|
||||
print(f"Camera blink failed: {e}")
|
||||
|
||||
|
||||
# --- Run GUI ---
|
||||
|
||||
Reference in New Issue
Block a user