Files
chatter/desktop.py
2025-10-11 01:46:09 +00:00

412 lines
17 KiB
Python

#!/usr/bin/env python3
"""
Desktop GUI wrapper for chatter.py using Tkinter.
Supports text messages and attachments (files) with clickable filenames.
Polls server every 1.5s for updates.
Settings now apply instantly.
Attachment preview images resize dynamically with the popup.
App icon set to chatter.png / chatter.ico for notifications.
"""
import threading
import tkinter as tk
from tkinter import filedialog, scrolledtext, messagebox
import requests, os, time, webbrowser, sys
from datetime import datetime
from plyer import notification
import cv2
from PIL import Image, ImageTk
from io import BytesIO
from dateutil import tz
SERVER = "https://chatter.wholeworldcoding.com"
SESSION = requests.Session()
LAST_MSG_IDS = set()
# Default font sizes
BASE_FONT_SIZE = 10
BASE_PADDING = 5
class ChatApp:
def __init__(self, root):
self.root = root
root.title("chatter")
root.geometry("900x700")
root.minsize(600, 500)
self.settings = {
"popup_notification": True,
"camera_notification": False,
"use_24_hour": False,
"date_position": "before",
"timezone": tz.tzlocal(),
"zoom": 1.0,
"dark_mode": False,
}
self.username = None
self.build_login_screen()
# ---------- Screens ----------
def build_login_screen(self):
for w in self.root.winfo_children():
w.destroy()
frame = tk.Frame(self.root)
frame.pack(expand=True)
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(frame, text="Username").pack()
self.entry_user = tk.Entry(frame, width=30)
self.entry_user.pack(pady=3)
tk.Label(frame, text="Password").pack()
self.entry_pass = tk.Entry(frame, show="*", width=30)
self.entry_pass.pack(pady=3)
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():
w.destroy()
self.root.rowconfigure(1, weight=1)
self.root.columnconfigure(0, weight=1)
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=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)
frame = tk.Frame(self.root)
frame.pack(fill=tk.X, pady=5)
frame.columnconfigure(0, weight=1)
self.msg_entry = tk.Entry(frame)
self.msg_entry.grid(row=0, column=0, sticky="ew", padx=5)
self.msg_entry.bind("<Return>", lambda e: self.send_message())
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)
self.apply_theme()
self.poll_messages()
# ---------- Settings ----------
def open_settings(self):
win = tk.Toplevel(self.root)
win.title("Settings")
win.geometry("400x400")
canvas = tk.Canvas(win)
scrollbar = tk.Scrollbar(win, orient="vertical", command=canvas.yview)
scroll_frame = tk.Frame(canvas)
scroll_frame.bind(
"<Configure>",
lambda e: canvas.configure(scrollregion=canvas.bbox("all"))
)
canvas.create_window((0, 0), window=scroll_frame, anchor="nw")
canvas.configure(yscrollcommand=scrollbar.set)
canvas.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
# Enable mouse wheel scrolling
def _on_mousewheel(event):
canvas.yview_scroll(-int(event.delta/120), "units")
scroll_frame.bind_all("<MouseWheel>", _on_mousewheel)
# Variables
popup_var = tk.BooleanVar(value=self.settings["popup_notification"])
cam_var = tk.BooleanVar(value=self.settings["camera_notification"])
hour24_var = tk.BooleanVar(value=self.settings.get("use_24_hour", False))
datepos_var = tk.StringVar(value=self.settings.get("date_position", "before"))
zoom_var = tk.DoubleVar(value=self.settings.get("zoom", 1.0))
dark_var = tk.BooleanVar(value=self.settings.get("dark_mode", False))
tk.Label(scroll_frame, text="Notification Options", font=("Courier", 12, "bold")).pack(pady=10)
tk.Checkbutton(scroll_frame, text="Popup Notification", variable=popup_var,
command=lambda: self.update_setting("popup_notification", popup_var.get())).pack(anchor="w", padx=20, pady=5)
tk.Checkbutton(scroll_frame, text="Camera Notification", variable=cam_var,
command=lambda: self.update_setting("camera_notification", cam_var.get())).pack(anchor="w", padx=20, pady=5)
tk.Label(scroll_frame, text="Clock Options", font=("Courier", 12, "bold")).pack(pady=10)
tk.Checkbutton(scroll_frame, text="Use 24-hour time", variable=hour24_var,
command=lambda: self.update_setting("use_24_hour", hour24_var.get())).pack(anchor="w", padx=20, pady=5)
tk.Label(scroll_frame, text="Date Position", font=("Courier", 12, "bold")).pack(pady=10)
tk.Radiobutton(scroll_frame, text="Before username", variable=datepos_var, value="before",
command=lambda: self.update_setting("date_position", datepos_var.get())).pack(anchor="w", padx=20)
tk.Radiobutton(scroll_frame, text="After username", variable=datepos_var, value="after",
command=lambda: self.update_setting("date_position", datepos_var.get())).pack(anchor="w", padx=20)
tk.Label(scroll_frame, text="Zoom (Font Size)", font=("Courier", 12, "bold")).pack(pady=10)
tk.Scale(scroll_frame, variable=zoom_var, from_=0.5, to=3.0, resolution=0.1, orient="horizontal",
command=lambda e: self.update_setting("zoom", zoom_var.get())).pack(padx=20, pady=5)
tk.Label(scroll_frame, text="Appearance", font=("Courier", 12, "bold")).pack(pady=10)
tk.Checkbutton(scroll_frame, text="Dark Mode", variable=dark_var,
command=lambda: self.update_setting("dark_mode", dark_var.get())).pack(anchor="w", padx=20, pady=5)
# ---------- Settings updater ----------
def update_setting(self, key, value):
self.settings[key] = value
if key in ["zoom", "dark_mode"]:
self.apply_theme()
self.poll_messages(force=True)
# ---------- Auth ----------
def login(self):
data = {"username": self.entry_user.get(), "password": self.entry_pass.get()}
r = SESSION.post(SERVER + "/login", data=data)
if r.url.endswith("/chat"):
self.username = data["username"]
self.build_chat_screen()
else:
messagebox.showerror("Error", "Login failed")
def register(self):
data = {"username": self.entry_user.get(), "password": self.entry_pass.get()}
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")
def logout(self):
SESSION.get(SERVER + "/logout")
self.username = None
self.build_login_screen()
# ---------- Messaging ----------
def send_message(self):
text = self.msg_entry.get().strip()
if not text:
return
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}")
# ---------- Attachment Preview ----------
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/")
# Title
title_label = tk.Label(popup, wraplength=380,
text=f"{username} sent: {file_name}",
font=self.get_font("italic"),
fg="grey")
title_label.pack(pady=5)
# Buttons
btn_frame = tk.Frame(popup)
btn_frame.pack(pady=5)
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)
# Image frame
img_frame = tk.Frame(popup)
img_frame.pack(expand=True, fill="both", pady=5)
try:
resp = requests.get(file_url)
pil_img = Image.open(BytesIO(resp.content))
except Exception:
tk.Label(img_frame, wraplength=380, text="You can preview the file, or download it.").pack(pady=5)
return
# Display image function
label_img = tk.Label(img_frame)
label_img.pack(expand=True)
def update_image(*args):
popup.update_idletasks()
max_width = popup.winfo_width() - 20
max_height = popup.winfo_height() - title_label.winfo_reqheight() - btn_frame.winfo_reqheight() - 40
img_copy = pil_img.copy()
img_copy.thumbnail((max_width, max_height))
tk_img = ImageTk.PhotoImage(img_copy)
label_img.config(image=tk_img)
label_img.image = tk_img
# Bind resize event
popup.bind("<Configure>", update_image)
update_image()
# ---------- Message Polling ----------
def poll_messages(self, force=False):
try:
r = SESSION.get(SERVER + "/api/messages")
if r.status_code != 200:
return
msgs = r.json()
new_ids = {m["id"] for m in msgs}
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)
padding = int(BASE_PADDING * self.settings.get("zoom", 1.0))
for m in msgs:
ts = m.get("created_at") or time.time()
try:
dt = datetime.fromtimestamp(float(ts)).astimezone(self.settings["timezone"])
ts_str = dt.strftime("%m/%d/%Y, %H:%M:%S") if self.settings.get("use_24_hour") else dt.strftime("%m/%d/%Y, %I:%M:%S %p")
except Exception:
ts_str = str(ts)
ts_tag = f"ts{m['id']}"
uname_tag = f"bold{m['id']}"
msg_tag = f"msg{m['id']}"
attach_tag = f"attach{m['id']}"
if m.get("attachment"):
attach_name = m["attachment"]
if self.settings["date_position"] == "before":
self.chat_box.insert(tk.END, f"[{ts_str}] {m['username']} sent a file: ", ts_tag)
else:
self.chat_box.insert(tk.END, f"{m['username']} [{ts_str}] sent a file: ", ts_tag)
self.chat_box.tag_config(ts_tag, foreground="grey", font=self.get_font("italic"))
self.chat_box.insert(tk.END, f"{attach_name}\n", attach_tag)
self.chat_box.tag_config(attach_tag, foreground="blue", font=self.get_font("normal"), underline=True)
self.chat_box.tag_bind(attach_tag, "<Button-1>",
lambda e, fn=attach_name, un=m["username"]: self.show_attachment_popup(fn, un))
else:
if self.settings["date_position"] == "before":
self.chat_box.insert(tk.END, f"[{ts_str}] ", ts_tag)
self.chat_box.insert(tk.END, f"{m['username']}", uname_tag)
self.chat_box.insert(tk.END, f": {m.get('text','')}\n", msg_tag)
else:
self.chat_box.insert(tk.END, f"{m['username']}", uname_tag)
self.chat_box.insert(tk.END, f" [{ts_str}]: ", ts_tag)
self.chat_box.insert(tk.END, f"{m.get('text','')}\n", msg_tag)
self.chat_box.tag_config(ts_tag, foreground="grey", font=self.get_font("grey"))
self.chat_box.tag_config(uname_tag, font=self.get_font("bold"))
self.chat_box.tag_config(msg_tag, font=self.get_font("normal"), spacing1=padding, spacing3=padding)
self.chat_box.config(state='disabled')
self.chat_box.yview(tk.END)
for m in new_msgs:
if m["username"] != self.username:
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 as e:
print(f"Poll error: {e}")
self.root.after(1500, self.poll_messages)
# ---------- Font helpers ----------
def get_font(self, kind="normal"):
scale = self.settings.get("zoom", 1.0)
size = max(int(BASE_FONT_SIZE * scale), 1)
if kind == "bold":
return ("Courier", size+1, "bold")
elif kind == "italic":
return ("Courier", size, "italic")
elif kind == "grey":
return ("Courier", max(size-1, 1))
else:
return ("Courier", size)
# ---------- Theme ----------
def apply_theme(self):
dark = self.settings.get("dark_mode", False)
bg = "#1e1e1e" if dark else "white"
fg = "#d4d4d4" if dark else "black"
entry_bg = "#2e2e2e" if dark else "white"
entry_fg = "#d4d4d4" if dark else "black"
self.chat_box.config(bg=bg, fg=fg, insertbackground=fg)
self.msg_entry.config(bg=entry_bg, fg=entry_fg, insertbackground=fg)
self.root.config(bg=bg)
# ---------- Notifications ----------
def notify_user(self, sender, message):
if self.settings.get("popup_notification"):
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()
def show_system_notification(self, title, message):
try:
# Determine icon path for PyInstaller
if getattr(sys, 'frozen', False):
base_path = sys._MEIPASS
else:
base_path = os.path.dirname(os.path.abspath(__file__))
icon_path = os.path.join(base_path, "chatter.ico")
notification.notify(
title=title,
message=message[:200],
app_name="chatter",
app_icon=icon_path, # Use .ico for Windows toast
timeout=5
)
except Exception as e:
print(f"Notification failed: {e}")
def blink_camera_light(self, blink_time=3):
try:
cap = cv2.VideoCapture(0)
if cap.isOpened():
ret, frame = cap.read()
if ret:
time.sleep(blink_time)
cap.release()
except Exception as e:
print(f"Camera blink failed: {e}")
# --- Run GUI ---
if __name__ == "__main__":
root = tk.Tk()
# Set app icon
try:
icon_img = tk.PhotoImage(file="chatter.png")
root.iconphoto(True, icon_img)
except Exception as e:
print(f"Failed to load icon: {e}")
app = ChatApp(root)
root.mainloop()