Skip to content
This repository was archived by the owner on Mar 10, 2026. It is now read-only.

Commit 5ae091b

Browse files
authored
Merge pull request #2 from Freedom-Club-FC/feature/contact-context-menu
feature: Right click Context Menu and Nicknames support
2 parents 0f96c17 + 89d8627 commit 5ae091b

File tree

6 files changed

+284
-19
lines changed

6 files changed

+284
-19
lines changed

assets/nicknames.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"nicknames": [
3+
"Contact",
4+
"Friend",
5+
"Anon",
6+
"Shadow",
7+
"Ghost",
8+
"Wire",
9+
"Cold"
10+
]
11+
}

logic/contacts.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,34 @@
1+
import secrets
2+
import string
3+
import json
4+
import math
15
import copy
26

7+
8+
def generate_nickname_id(length: int = 4) -> str:
9+
# Calculate nickname ID: digits get >= letters
10+
digit_len = math.ceil(length / 2)
11+
letter_len = length - digit_len
12+
13+
digits = ''.join(secrets.choice(string.digits) for _ in range(digit_len))
14+
letters = ''.join(secrets.choice(string.ascii_letters) for _ in range(letter_len))
15+
16+
return letters + digits
17+
18+
def generate_random_nickname(user_data: dict, user_data_lock, contact_id: str, nicknames_prefixes_file: str = "assets/nicknames.json", nickname_id_len = 4) -> str:
19+
with open(nicknames_prefixes_file, "r", encoding="utf-8") as f:
20+
nickname_prefixes = json.load(f)["nicknames"]
21+
22+
with user_data_lock:
23+
existing_nicknames = {v["nickname"] for v in user_data.get("contacts", {}).values()}
24+
25+
26+
while True:
27+
nickname = secrets.choice(nickname_prefixes) + " " + generate_nickname_id(length = nickname_id_len)
28+
if nickname not in existing_nicknames:
29+
return nickname
30+
31+
332
def save_contact(user_data: dict, user_data_lock, contact_id: str, contact_public_key: bytes) -> None:
433
with user_data_lock:
534
if contact_id in user_data["contacts"]:
@@ -11,6 +40,7 @@ def save_contact(user_data: dict, user_data_lock, contact_id: str, contact_publi
1140

1241

1342
user_data["contacts"][contact_id] = {
43+
"nickname": None,
1444
"lt_sign_public_key": contact_public_key,
1545
"lt_sign_key_smp": {
1646
"verified": False,

ui/chat_window.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def on_send(self, event=None):
6060
self.append_message(f"You: {message}")
6161
return "break"
6262

63-
def append_message(self, message: str, save_msg: bool = True):
63+
def append_message(self, message: str, save_msg: bool = True, contact_nickname: str = "Contact"):
6464
self.chat_display.config(state="normal")
6565
if message.startswith("You:"):
6666
self.chat_display.insert("end", "You:", "you")
@@ -71,8 +71,8 @@ def append_message(self, message: str, save_msg: bool = True):
7171
self.chat_display.insert("end", message[1:] + "\n")
7272

7373
else:
74-
self.chat_display.insert("end", "Contact:", "contact")
75-
self.chat_display.insert("end", message[8:] + "\n")
74+
self.chat_display.insert("end", contact_nickname + ":", "contact")
75+
self.chat_display.insert("end", message[len(contact_nickname):] + "\n")
7676

7777

7878

ui/contact_list.py

Lines changed: 104 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
from ui.add_contact_prompt import AddContactPrompt
77
from ui.smp_setup_window import SMPSetupWindow
88
from ui.smp_question_window import SMPQuestionWindow
9+
from ui.contact_nickname_prompt import ContactNicknamePrompt
910
from logic.authentication import authenticate_account
10-
from logic.storage import check_account_file, load_account_data
11+
from logic.storage import check_account_file, save_account_data, load_account_data
1112
from logic.background_worker import background_worker
1213
from logic.utils import thread_failsafe_wrapper
1314
import tkinter as tk
@@ -105,7 +106,14 @@ def poll_ui_queue(self):
105106
logger.debug("Opening chat window for contact (%s) because a new message arrived", msg["contact_id"])
106107
self.chat_windows_store_tmp[msg["contact_id"]] = ChatWindow(self, msg["contact_id"], self.ui_queue)
107108

108-
self.chat_windows_store_tmp[msg["contact_id"]].append_message("Contact: " + msg["message"])
109+
with self.user_data_lock:
110+
contact_nickname = self.user_data["contacts"][msg["contact_id"]]["nickname"]
111+
112+
113+
if not contact_nickname:
114+
contact_nickname = "Contact"
115+
116+
self.chat_windows_store_tmp[msg["contact_id"]].append_message(contact_nickname + ": " + msg["message"], contact_nickname=contact_nickname)
109117

110118
elif msg["type"] == "chat_closed":
111119
del self.chat_windows_store_tmp[msg["contact_id"]]
@@ -163,7 +171,7 @@ def show_contacts(self):
163171
cursor="hand2"
164172
)
165173
username_label.pack(side="left")
166-
username_label.bind("<Button-1>", lambda e: self.copy_to_clipboard(username))
174+
username_label.bind("<Button-1>", lambda e: self.copy_to_clipboard(username, "Your User ID has been copied to clipboard."))
167175

168176
header_frame = tk.Frame(self, bg="black")
169177
header_frame.pack(pady=10)
@@ -191,35 +199,117 @@ def show_contacts(self):
191199
add_button.image = plus_icon # Prevents garbage collection
192200
add_button.pack(side="left", padx=(5, 0))
193201

194-
self.contact_frame = tk.Frame(self, bg="black")
195-
self.contact_frame.pack(fill="both", expand=True)
196202

197203

198-
# Insert our saved contacts
199-
for contact_id in self.user_data["contacts"]:
200-
self.new_contact(contact_id)
204+
canvas = tk.Canvas(self, bg="black", highlightthickness=0)
205+
scrollbar = tk.Scrollbar(self, orient="vertical", command=canvas.yview)
206+
self.contact_frame = tk.Frame(canvas, bg="black")
207+
208+
self.contact_frame.bind(
209+
"<Configure>",
210+
lambda e: canvas.configure(scrollregion=canvas.bbox("all"))
211+
)
212+
213+
contact_window = canvas.create_window((0, 0), window=self.contact_frame, anchor="nw")
214+
215+
canvas.configure(yscrollcommand=scrollbar.set)
201216

217+
canvas.pack(side="left", fill="both", expand=True)
218+
scrollbar.pack(side="right", fill="y")
219+
220+
canvas.bind_all("<MouseWheel>", lambda e: canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")) # Windows / Linux mouse scrolling support
221+
canvas.bind_all("<Button-4>", lambda e: canvas.yview_scroll(-1, "units")) # MacOS
222+
canvas.bind_all("<Button-5>", lambda e: canvas.yview_scroll(1, "units")) # MacOS again
223+
224+
canvas_frame = canvas.create_window((0, 0), window=self.contact_frame, anchor="nw")
225+
226+
canvas.bind("<Configure>", lambda e: canvas.itemconfig(canvas_frame, width=e.width))
227+
228+
229+
# Draw our saved contacts
230+
self.draw_contact_list()
202231

203232
# We initialize the background worker thread and other hooks here to prevent race conditions
204233
self.init_hooks_and_background_worker()
205234

235+
def on_mousewheel(event):
236+
canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
237+
238+
206239
def new_contact(self, contact_id):
207-
btn = tk.Button(
240+
with self.user_data_lock:
241+
contact_name = contact_id if not self.user_data["contacts"][contact_id]["nickname"] else self.user_data["contacts"][contact_id]["nickname"]
242+
contact_is_verified = self.user_data["contacts"][contact_id]["lt_sign_key_smp"]["verified"]
243+
244+
button = tk.Button(
208245
self.contact_frame,
209-
text=contact_id,
246+
text=contact_name,
210247
bg="gray15",
211248
fg="white",
212249
relief="flat",
213250
anchor="w",
214251
command=lambda: self.open_chat(contact_id)
215252
)
216-
btn.pack(fill="x", padx=15, pady=5)
253+
button.pack(fill="x", padx=15, pady=5)
254+
255+
context_menu = tk.Menu(self, tearoff=0)
256+
context_menu.add_command(
257+
label="Copy Contact ID",
258+
command=lambda: self.copy_to_clipboard(contact_id, "Contact ID has been copied to the clipboard")
259+
)
260+
context_menu.add_separator()
261+
262+
# If no nickname is set
263+
if (contact_name == contact_id):
264+
# We only allow setting nicknames after SMP verification succeeds
265+
if contact_is_verified:
266+
context_menu.add_command(
267+
label="Set nickname",
268+
command=lambda: self.change_contact_nickname(contact_id)
269+
)
270+
else:
271+
context_menu.add_command(
272+
label="Change nickname",
273+
command=lambda: self.change_contact_nickname(contact_id)
274+
)
275+
276+
context_menu.add_separator()
277+
278+
context_menu.add_command(
279+
label="Remove nickname",
280+
command=lambda: self.remove_contact_nickname(contact_id)
281+
)
282+
283+
button.bind("<Button-3>", lambda event: context_menu.tk_popup(event.x_root, event.y_root)) # Windows / Linux
284+
button.bind("<Button-2>", lambda event: context_menu.tk_popup(event.x_root, event.y_root)) # MacOS
285+
286+
def change_contact_nickname(self, contact_id):
287+
ContactNicknamePrompt(self, contact_id)
288+
289+
def remove_contact_nickname(self, contact_id):
290+
with self.user_data_lock:
291+
self.user_data["contacts"][contact_id]["nickname"] = None
292+
293+
logger.info("Removed nickname for contact (%s)", contact_id)
294+
save_account_data(self.user_data, self.user_data_lock)
295+
self.draw_contact_list()
296+
297+
def draw_contact_list(self):
298+
logger.debug("Redrawing the contact list")
299+
for widget in self.contact_frame.winfo_children():
300+
widget.destroy()
301+
302+
with self.user_data_lock:
303+
contact_ids = list(self.user_data["contacts"].keys())
304+
305+
for contact_id in self.user_data["contacts"]:
306+
self.new_contact(contact_id)
217307

218-
219-
def copy_to_clipboard(self, text):
308+
logger.debug("Redrew the contact list")
309+
def copy_to_clipboard(self, text, success_message):
220310
self.clipboard_clear()
221311
self.clipboard_append(text)
222-
messagebox.showinfo("Copied", "Your User ID has been copied to clipboard.")
312+
messagebox.showinfo("Copied", success_message)
223313

224314
def open_chat(self, contact_id):
225315
with self.user_data_lock:

ui/contact_nickname_prompt.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
from tkinter import messagebox
2+
from ui.utils import *
3+
from logic.contacts import generate_random_nickname
4+
from logic.storage import save_account_data
5+
import tkinter as tk
6+
import logging
7+
8+
logger = logging.getLogger(__name__)
9+
10+
class ContactNicknamePrompt(tk.Toplevel):
11+
def __init__(self, master, contact_id):
12+
super().__init__(master)
13+
self.title("Set Nickname")
14+
self.geometry("460x300")
15+
self.configure(bg="black")
16+
self.resizable(False, False)
17+
18+
self.contact_id = contact_id
19+
self.random_nickname = None
20+
21+
# Top warning text (We use text instead of label to color key warning parts)
22+
warning_text = tk.Text(
23+
self,
24+
bg="black",
25+
fg="white",
26+
font=("Helvetica", 11),
27+
wrap="word",
28+
height=9,
29+
width=50,
30+
borderwidth=0,
31+
highlightthickness=0,
32+
relief="flat"
33+
)
34+
warning_text.insert("1.0", "Choose an anonymous nickname.\n")
35+
warning_text.insert("end", "Do NOT ", "warning_italic")
36+
warning_text.insert("end", "use real names or anything identifying.\n")
37+
warning_text.insert("end", "Good ", "good_bold")
38+
warning_text.insert("end", "examples: Contact 1, Friend A, C1, etc.\n")
39+
warning_text.insert("end", "Bad ", "warning_bold")
40+
warning_text.insert("end", "examples: George, CIA contact, Z Dealer, etc.\n\n")
41+
warning_text.insert("end", "WARNING: ", "warning_bold")
42+
warning_text.insert("end", "If your device hard disk is ever compromised, custom nicknames can link handles to people.\n")
43+
warning_text.insert("end", "Please proceed with EXTREME caution.", "warning_italic")
44+
45+
warning_text.tag_config("italic", font=("Helvetica", 10, "italic"))
46+
warning_text.tag_config("warning_bold", font=("Helvetica", 10, "bold"), foreground="red")
47+
warning_text.tag_config("warning_italic", font=("Helvetica", 10, "italic"), foreground="red")
48+
49+
warning_text.tag_config("good_bold", font=("Helvetica", 10, "bold"), foreground="green")
50+
51+
warning_text.configure(state="disabled")
52+
warning_text.pack(pady=(10, 10))
53+
54+
55+
# Entry + generator button container
56+
entry_frame = tk.Frame(self, bg="black")
57+
entry_frame.pack(pady=5)
58+
59+
self.entry = tk.Entry(entry_frame, font=("Helvetica", 12), width=20)
60+
self.entry.pack(side="left", padx=(0, 5))
61+
self.entry.focus()
62+
63+
# Random nickname generator button
64+
tk.Button(
65+
entry_frame,
66+
text="Randomize",
67+
command=self.generate_nickname,
68+
bg="gray25",
69+
fg="white",
70+
font=("Helvetica", 10, "bold"),
71+
width=10
72+
).pack(side="left")
73+
74+
# Save button
75+
tk.Button(
76+
self,
77+
text="Save nickname",
78+
command=self.submit_nickname,
79+
bg="gray20",
80+
fg="white",
81+
font=("Helvetica", 10, "bold"),
82+
width=15
83+
).pack(pady=15)
84+
85+
# Enter key = save
86+
self.entry.bind("<Return>", lambda e: self.submit_nickname())
87+
88+
def generate_nickname(self):
89+
nickname = generate_random_nickname(self.master.user_data, self.master.user_data_lock, self.contact_id)
90+
self.random_nickname = nickname
91+
92+
self.entry.delete(0, tk.END)
93+
self.entry.insert(0, nickname)
94+
95+
def submit_nickname(self):
96+
nickname = self.entry.get().strip()
97+
98+
if not nickname:
99+
messagebox.showerror("Error", "Field cannot be empty!")
100+
return
101+
102+
if len(nickname) > 32:
103+
messagebox.showerror("Error", "Nickname must be less than 32 characters long!")
104+
return1
105+
106+
if nickname != self.random_nickname:
107+
if not messagebox.askyesno(
108+
"Warning",
109+
"You did not use the Randomize button.\n\n"
110+
"Custom nicknames can link your contacts to real-world identities.\n"
111+
"This risks their privacy if your device is ever compromised.\n\n"
112+
"Are you sure you want to proceed?"
113+
):
114+
return
115+
116+
if not messagebox.askyesno(
117+
"Final Warning",
118+
"Custom nicknames can permanently compromise both your privacy and your contact’s.\n"
119+
"If your device is ever seized or leaked, this link may expose real identities.\n\n"
120+
"Are you ABSOLUTELY sure you want to proceed?"
121+
):
122+
return
123+
124+
with self.master.user_data_lock:
125+
self.master.user_data["contacts"][self.contact_id]["nickname"] = nickname
126+
127+
save_account_data(self.master.user_data, self.master.user_data_lock)
128+
logger.info("Updated contact (%s) nickname to: %s", self.contact_id, nickname)
129+
130+
self.master.draw_contact_list()
131+
self.destroy()

ui/password_window.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def submit(self):
4545
self.status_label.config(text="Passwords do not match.")
4646
return
4747

48-
if len(password) < 8:
48+
if password and len(password) < 8:
4949
if not messagebox.askyesno("No Password", "Password is less than 8 characters long, this is insecure. Are you sure you want to continue?"):
5050
return
5151

@@ -55,7 +55,10 @@ def submit(self):
5555
if not messagebox.askyesno("No Password", "You entered no password. Continue anyway?"):
5656
return
5757

58-
if not messagebox.askyesno("No Password", "Disabling encryption allows anyone with access to your device to see your contacts, and cryptographic keys. Do this only in fully trusted environments. Are you sure?"):
58+
if not messagebox.askyesno(
59+
"No Password",
60+
"Disabling encryption allows anyone with access to your device to see your contacts, and cryptographic keys. Do this only in fully trusted environments. Are you absolutely sure?"
61+
):
5962
return
6063

6164
self.destroy()

0 commit comments

Comments
 (0)