shellwhisper-cli 1.0.2__tar.gz → 1.0.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {shellwhisper_cli-1.0.2 → shellwhisper_cli-1.0.4}/PKG-INFO +1 -1
- {shellwhisper_cli-1.0.2 → shellwhisper_cli-1.0.4}/pyproject.toml +6 -3
- {shellwhisper_cli-1.0.2 → shellwhisper_cli-1.0.4}/shellwhisper_cli.egg-info/PKG-INFO +1 -1
- {shellwhisper_cli-1.0.2 → shellwhisper_cli-1.0.4}/shellwhisper_cli.egg-info/SOURCES.txt +1 -10
- shellwhisper_cli-1.0.2/src/components/sidebar.py +0 -90
- shellwhisper_cli-1.0.2/src/screens/chat_screen.py +0 -661
- shellwhisper_cli-1.0.2/src/screens/forgot_password.py +0 -79
- shellwhisper_cli-1.0.2/src/screens/join_screen.py +0 -23
- shellwhisper_cli-1.0.2/src/screens/login.py +0 -104
- shellwhisper_cli-1.0.2/src/screens/private_whisper_screen.py +0 -26
- shellwhisper_cli-1.0.2/src/screens/room_action_screen.py +0 -35
- shellwhisper_cli-1.0.2/src/screens/security_screen.py +0 -45
- shellwhisper_cli-1.0.2/src/utils/api_client.py +0 -133
- {shellwhisper_cli-1.0.2 → shellwhisper_cli-1.0.4}/LICENSE +0 -0
- {shellwhisper_cli-1.0.2 → shellwhisper_cli-1.0.4}/README.md +0 -0
- {shellwhisper_cli-1.0.2 → shellwhisper_cli-1.0.4}/setup.cfg +0 -0
- {shellwhisper_cli-1.0.2 → shellwhisper_cli-1.0.4}/shellwhisper_cli.egg-info/dependency_links.txt +0 -0
- {shellwhisper_cli-1.0.2 → shellwhisper_cli-1.0.4}/shellwhisper_cli.egg-info/entry_points.txt +0 -0
- {shellwhisper_cli-1.0.2 → shellwhisper_cli-1.0.4}/shellwhisper_cli.egg-info/requires.txt +0 -0
- {shellwhisper_cli-1.0.2 → shellwhisper_cli-1.0.4}/shellwhisper_cli.egg-info/top_level.txt +0 -0
- {shellwhisper_cli-1.0.2 → shellwhisper_cli-1.0.4}/src/app.py +0 -0
- {shellwhisper_cli-1.0.2 → shellwhisper_cli-1.0.4}/src/events.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "shellwhisper-cli"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.4"
|
|
8
8
|
description = "A sleek terminal-based real-time chat client for ShellWhisper"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -24,5 +24,8 @@ dependencies = [
|
|
|
24
24
|
[project.scripts]
|
|
25
25
|
shellwhisper = "src.app:main"
|
|
26
26
|
|
|
27
|
-
[tool.setuptools
|
|
28
|
-
|
|
27
|
+
[tool.setuptools]
|
|
28
|
+
packages = ["src"]
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.package-data]
|
|
31
|
+
"*" = ["*.tcss", "*.css"]
|
|
@@ -8,13 +8,4 @@ shellwhisper_cli.egg-info/entry_points.txt
|
|
|
8
8
|
shellwhisper_cli.egg-info/requires.txt
|
|
9
9
|
shellwhisper_cli.egg-info/top_level.txt
|
|
10
10
|
src/app.py
|
|
11
|
-
src/events.py
|
|
12
|
-
src/components/sidebar.py
|
|
13
|
-
src/screens/chat_screen.py
|
|
14
|
-
src/screens/forgot_password.py
|
|
15
|
-
src/screens/join_screen.py
|
|
16
|
-
src/screens/login.py
|
|
17
|
-
src/screens/private_whisper_screen.py
|
|
18
|
-
src/screens/room_action_screen.py
|
|
19
|
-
src/screens/security_screen.py
|
|
20
|
-
src/utils/api_client.py
|
|
11
|
+
src/events.py
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
from textual.widgets import Static, Label, Button, LoadingIndicator
|
|
2
|
-
from textual.containers import Vertical
|
|
3
|
-
from textual.app import ComposeResult
|
|
4
|
-
|
|
5
|
-
class Sidebar(Static):
|
|
6
|
-
def compose(self) -> ComposeResult:
|
|
7
|
-
yield Label("ShellWhisper", id="sidebar-title")
|
|
8
|
-
yield Static(classes='spacer')
|
|
9
|
-
|
|
10
|
-
yield Button("🌐 ROOM ACTIONS", id="btn_room_mgmt")
|
|
11
|
-
yield Button("✉️ PRIVATE WHISPER", id="btn_private")
|
|
12
|
-
|
|
13
|
-
yield Static(classes='spacer')
|
|
14
|
-
yield Label("MY ROOMS", id="section-label")
|
|
15
|
-
|
|
16
|
-
with Vertical(id="rooms-list"):
|
|
17
|
-
yield LoadingIndicator()
|
|
18
|
-
|
|
19
|
-
yield Static(classes='spacer')
|
|
20
|
-
yield Button("Logout", variant="error", id="logout_btn")
|
|
21
|
-
|
|
22
|
-
# async def update_rooms(self, rooms: list):
|
|
23
|
-
# rooms_list = self.query_one("#rooms-list")
|
|
24
|
-
# await rooms_list.query("*").remove()
|
|
25
|
-
|
|
26
|
-
# if not rooms:
|
|
27
|
-
# await rooms_list.mount(Label("No rooms yet...", classes="empty-msg"))
|
|
28
|
-
# else:
|
|
29
|
-
# current_user = self.app.current_user
|
|
30
|
-
|
|
31
|
-
# for room in rooms:
|
|
32
|
-
# display_name = room['roomName']
|
|
33
|
-
|
|
34
|
-
# if room.get("type") == "PRIVATE":
|
|
35
|
-
# clean_name = display_name.replace("private_", "")
|
|
36
|
-
|
|
37
|
-
# parts = clean_name.split("_")
|
|
38
|
-
|
|
39
|
-
# if len(parts) == 2:
|
|
40
|
-
# display_name = parts[1] if parts[0] == current_user else parts[0]
|
|
41
|
-
|
|
42
|
-
# btn_label = f"💬 {display_name}"
|
|
43
|
-
# else:
|
|
44
|
-
# btn_label = f"#{display_name}"
|
|
45
|
-
|
|
46
|
-
# btn = Button(
|
|
47
|
-
# # label=f"#{room['roomName']}",
|
|
48
|
-
# label=btn_label,
|
|
49
|
-
# id=f"room_{room['id']}",
|
|
50
|
-
# classes="room-link"
|
|
51
|
-
# )
|
|
52
|
-
# await rooms_list.mount(btn)
|
|
53
|
-
|
|
54
|
-
async def update_rooms(self, rooms: list):
|
|
55
|
-
rooms_list = self.query_one("#rooms-list")
|
|
56
|
-
await rooms_list.query("*").remove()
|
|
57
|
-
|
|
58
|
-
if not rooms:
|
|
59
|
-
await rooms_list.mount(Label("No rooms yet...", classes="empty-msg"))
|
|
60
|
-
else:
|
|
61
|
-
current_user = self.app.current_user
|
|
62
|
-
|
|
63
|
-
for room in rooms:
|
|
64
|
-
room_name = room['roomName']
|
|
65
|
-
room_type = room.get("type", "GROUP")
|
|
66
|
-
|
|
67
|
-
if room_type == "PRIVATE":
|
|
68
|
-
raw = room_name
|
|
69
|
-
|
|
70
|
-
if raw.startswith("private_"):
|
|
71
|
-
raw = raw[len("private_"):]
|
|
72
|
-
|
|
73
|
-
other = raw
|
|
74
|
-
|
|
75
|
-
if raw.startswith(current_user + "_"):
|
|
76
|
-
other = raw[len(current_user) + 1:]
|
|
77
|
-
elif raw.endswith("_" + current_user):
|
|
78
|
-
other = raw[: -(len(current_user) + 1)]
|
|
79
|
-
|
|
80
|
-
btn_label = f"💬 {other}"
|
|
81
|
-
else:
|
|
82
|
-
btn_label = room_name
|
|
83
|
-
|
|
84
|
-
btn = Button(
|
|
85
|
-
label=btn_label,
|
|
86
|
-
id=f"room_{room['id']}",
|
|
87
|
-
classes="room-link"
|
|
88
|
-
)
|
|
89
|
-
|
|
90
|
-
await rooms_list.mount(btn)
|
|
@@ -1,661 +0,0 @@
|
|
|
1
|
-
from textual.widgets import Header, Footer, Input, Label, RichLog, Button
|
|
2
|
-
from textual.screen import Screen
|
|
3
|
-
from textual.containers import Vertical, Horizontal
|
|
4
|
-
|
|
5
|
-
from rich.markup import escape
|
|
6
|
-
|
|
7
|
-
from src.events import NewWhisperReceived
|
|
8
|
-
from src.components.sidebar import Sidebar
|
|
9
|
-
from src.screens.room_action_screen import RoomActionScreen
|
|
10
|
-
from src.screens.security_screen import SecurityScreen
|
|
11
|
-
from src.screens.private_whisper_screen import PrivateWhisperPromptScreen
|
|
12
|
-
|
|
13
|
-
import json
|
|
14
|
-
import base64
|
|
15
|
-
import os
|
|
16
|
-
|
|
17
|
-
from datetime import datetime
|
|
18
|
-
|
|
19
|
-
class ChatScreen(Screen):
|
|
20
|
-
TITLE = "ShellWhisper"
|
|
21
|
-
BINDINGS = [
|
|
22
|
-
("ctrl+l", "logout", "Logout"),
|
|
23
|
-
("ctrl+d", "toggle_dark", "Toggle dark mode"),
|
|
24
|
-
("ctrl+q", "quit", "Quit"),
|
|
25
|
-
]
|
|
26
|
-
|
|
27
|
-
def compose(self):
|
|
28
|
-
yield Header()
|
|
29
|
-
|
|
30
|
-
with Horizontal():
|
|
31
|
-
yield Sidebar(id="sidebar")
|
|
32
|
-
|
|
33
|
-
with Vertical(id="chat-view-container"):
|
|
34
|
-
yield Label("Select a room to start whispering...", id="empty-view")
|
|
35
|
-
|
|
36
|
-
with Vertical(id="chat-view"):
|
|
37
|
-
yield RichLog(id="chat_log", highlight=True, markup=True)
|
|
38
|
-
yield Input(
|
|
39
|
-
placeholder="Type a whisper or @help for commands...",
|
|
40
|
-
id="chat_input",
|
|
41
|
-
)
|
|
42
|
-
|
|
43
|
-
yield Footer()
|
|
44
|
-
|
|
45
|
-
async def on_mount(self) -> None:
|
|
46
|
-
self.sub_title = f"Logged in as {self.app.current_user}"
|
|
47
|
-
self._current_subscription_id = None
|
|
48
|
-
self.current_room_messages = []
|
|
49
|
-
self._pending_room_data = None
|
|
50
|
-
self.rooms = []
|
|
51
|
-
|
|
52
|
-
self.app.connect_websocket()
|
|
53
|
-
self.set_interval(5.0, self.refresh_rooms)
|
|
54
|
-
self.run_worker(self._load_rooms_worker, thread=True)
|
|
55
|
-
|
|
56
|
-
# --- Sidebar / room loading --- #
|
|
57
|
-
|
|
58
|
-
def _load_rooms_worker(self) -> None:
|
|
59
|
-
rooms = self.fetch_user_rooms()
|
|
60
|
-
self.rooms = rooms
|
|
61
|
-
self.app.call_from_thread(self.update_sidebar_data, rooms)
|
|
62
|
-
|
|
63
|
-
def refresh_rooms(self) -> None:
|
|
64
|
-
self.run_worker(self._load_rooms_worker, thread=True)
|
|
65
|
-
|
|
66
|
-
def update_sidebar_data(self, rooms) -> None:
|
|
67
|
-
sidebar = self.query_one("#sidebar")
|
|
68
|
-
self.app.run_worker(sidebar.update_rooms(rooms))
|
|
69
|
-
|
|
70
|
-
### BUTTON HANDLING ###
|
|
71
|
-
|
|
72
|
-
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
73
|
-
if event.button.id == "btn_room_mgmt":
|
|
74
|
-
self.app.push_screen(RoomActionScreen(), self._on_room_action_dismissed)
|
|
75
|
-
elif event.button.id == "btn_private":
|
|
76
|
-
self.app.push_screen(PrivateWhisperPromptScreen(), self._on_private_chat_dismissed)
|
|
77
|
-
elif event.button.id == "logout_btn":
|
|
78
|
-
self.logout_process()
|
|
79
|
-
elif "room-link" in event.button.classes:
|
|
80
|
-
room_id = event.button.id.replace("room_", "")
|
|
81
|
-
self.switch_to_room(room_id)
|
|
82
|
-
|
|
83
|
-
# --- Private whisper flow --- #
|
|
84
|
-
|
|
85
|
-
def _on_private_chat_dismissed(self, target_username: str | None) -> None:
|
|
86
|
-
if not target_username:
|
|
87
|
-
return
|
|
88
|
-
|
|
89
|
-
self.run_worker(lambda: self._start_private_chat_worker(target_username), thread=True)
|
|
90
|
-
|
|
91
|
-
def _start_private_chat_worker(self, target_username: str) -> None:
|
|
92
|
-
try:
|
|
93
|
-
response = self.app.api.start_private_chat(target_username)
|
|
94
|
-
|
|
95
|
-
if response.status_code in (200, 201):
|
|
96
|
-
room_data = response.json()
|
|
97
|
-
|
|
98
|
-
self.app.call_from_thread(
|
|
99
|
-
self.app.notify,
|
|
100
|
-
f"Private channel open with {escape(target_username)}!",
|
|
101
|
-
severity="success",
|
|
102
|
-
)
|
|
103
|
-
|
|
104
|
-
rooms = self.fetch_user_rooms()
|
|
105
|
-
self.rooms = rooms
|
|
106
|
-
self.app.call_from_thread(self.update_sidebar_data, rooms)
|
|
107
|
-
self.app.call_from_thread(self._auto_switch_to_room, room_data.get("id"))
|
|
108
|
-
|
|
109
|
-
elif response.status_code == 404:
|
|
110
|
-
self.app.call_from_thread(
|
|
111
|
-
self.app.notify,
|
|
112
|
-
f"User '{escape(target_username)}' not found!",
|
|
113
|
-
severity="error"
|
|
114
|
-
)
|
|
115
|
-
else:
|
|
116
|
-
self.app.call_from_thread(
|
|
117
|
-
self.app.notify,
|
|
118
|
-
f"Action failed ({response.status_code}): {escape(response.text)}",
|
|
119
|
-
severity="error",
|
|
120
|
-
)
|
|
121
|
-
except Exception as e:
|
|
122
|
-
self.app.call_from_thread(
|
|
123
|
-
self.app.notify,
|
|
124
|
-
f"Network error: {escape(str(e))}",
|
|
125
|
-
severity="error",
|
|
126
|
-
)
|
|
127
|
-
|
|
128
|
-
### RoomActionScreen ###
|
|
129
|
-
|
|
130
|
-
def _on_room_action_dismissed(self, data: dict | None) -> None:
|
|
131
|
-
if not data:
|
|
132
|
-
return
|
|
133
|
-
|
|
134
|
-
self._pending_room_data = data
|
|
135
|
-
self.app.push_screen(
|
|
136
|
-
SecurityScreen(action=data["action"], room_name=data["name"]),
|
|
137
|
-
self._on_security_action_dismissed,
|
|
138
|
-
)
|
|
139
|
-
|
|
140
|
-
def _on_security_action_dismissed(self, security_key: str | None) -> None:
|
|
141
|
-
if not security_key or not self._pending_room_data:
|
|
142
|
-
return
|
|
143
|
-
|
|
144
|
-
action = self._pending_room_data["action"]
|
|
145
|
-
room_name = self._pending_room_data["name"]
|
|
146
|
-
self._pending_room_data = None
|
|
147
|
-
|
|
148
|
-
if action == "create_btn":
|
|
149
|
-
self._do_create_room(room_name, security_key)
|
|
150
|
-
elif action == "join_btn":
|
|
151
|
-
self._do_join_room(room_name, security_key)
|
|
152
|
-
|
|
153
|
-
### CREATE ROOM ###
|
|
154
|
-
|
|
155
|
-
def _do_create_room(self, room_name: str, security_key: str) -> None:
|
|
156
|
-
response = self.app.api.create_room(room_name, security_key)
|
|
157
|
-
|
|
158
|
-
if response.status_code == 201:
|
|
159
|
-
room_data = response.json()
|
|
160
|
-
self.app.notify(f"Room '{escape(room_name)}' created!", severity="success")
|
|
161
|
-
self.refresh_rooms()
|
|
162
|
-
self.call_after_refresh(self._auto_switch_to_room, room_data.get("id"))
|
|
163
|
-
elif response.status_code == 400:
|
|
164
|
-
self.app.notify(
|
|
165
|
-
escape(response.text) or "A room with that name already exists.",
|
|
166
|
-
severity="error",
|
|
167
|
-
)
|
|
168
|
-
elif response.status_code == 404:
|
|
169
|
-
self.app.notify("User account not found on server.", severity="error")
|
|
170
|
-
else:
|
|
171
|
-
self.app.notify(
|
|
172
|
-
f"Failed to create room ({response.status_code}): {escape(response.text)}",
|
|
173
|
-
severity="error",
|
|
174
|
-
)
|
|
175
|
-
|
|
176
|
-
### JOIN ROOM ###
|
|
177
|
-
|
|
178
|
-
def _do_join_room(self, room_name: str, security_key: str) -> None:
|
|
179
|
-
response = self.app.api.join_room(room_name, security_key)
|
|
180
|
-
|
|
181
|
-
if response.status_code == 200:
|
|
182
|
-
room_data = response.json()
|
|
183
|
-
self.app.notify(f"Joined '{escape(room_name)}'!", severity="success")
|
|
184
|
-
self.refresh_rooms()
|
|
185
|
-
self.call_after_refresh(self._auto_switch_to_room, room_data.get("id"))
|
|
186
|
-
elif response.status_code == 401:
|
|
187
|
-
self.app.notify("Wrong security key - try again.", severity="error")
|
|
188
|
-
elif response.status_code == 404:
|
|
189
|
-
self.app.notify(
|
|
190
|
-
f"Room '{escape(room_name)}' not found. Check the name and try again.",
|
|
191
|
-
severity="error",
|
|
192
|
-
)
|
|
193
|
-
else:
|
|
194
|
-
self.app.notify(
|
|
195
|
-
f"Failed to join room ({response.status_code}): {escape(response.text)}",
|
|
196
|
-
severity="error",
|
|
197
|
-
)
|
|
198
|
-
|
|
199
|
-
### DELETE ROOM ###
|
|
200
|
-
|
|
201
|
-
def _do_delete_room(self, room_id: str, security_str: str = "") -> None:
|
|
202
|
-
room = next((r for r in self.rooms if r["id"] == room_id), None)
|
|
203
|
-
room_name = room["roomName"] if room else room_id
|
|
204
|
-
|
|
205
|
-
try:
|
|
206
|
-
response = self.app.api.delete_room(room_id, security_str)
|
|
207
|
-
|
|
208
|
-
if response.status_code == 200:
|
|
209
|
-
# self.app.notify(f"Room '{escape(room_name)}' deleted successfully.", severity="success")
|
|
210
|
-
self._stomp_unsubscribe()
|
|
211
|
-
self.app.current_room_id = None
|
|
212
|
-
self.current_room_messages = []
|
|
213
|
-
self.app.file_cache.clear()
|
|
214
|
-
self._clear_to_empty_viewport()
|
|
215
|
-
self.refresh_rooms()
|
|
216
|
-
elif response.status_code == 403:
|
|
217
|
-
self.app.notify("You don't have permission to delete this room.", severity="error")
|
|
218
|
-
elif response.status_code == 404:
|
|
219
|
-
self.app.notify("Room not found.", severity="error")
|
|
220
|
-
else:
|
|
221
|
-
self.app.notify(
|
|
222
|
-
f"Failed to delete room ({response.status_code}): {escape(response.text)}",
|
|
223
|
-
severity="error"
|
|
224
|
-
)
|
|
225
|
-
except Exception as e:
|
|
226
|
-
self.app.notify(f"Network error: {escape(str(e))}", severity="error")
|
|
227
|
-
|
|
228
|
-
### LEAVE ROOM ###
|
|
229
|
-
|
|
230
|
-
def _do_leave_room(self, room_id: str) -> None:
|
|
231
|
-
room = next((r for r in self.rooms if r["id"] == room_id), None)
|
|
232
|
-
room_name = room["roomName"] if room else room_id
|
|
233
|
-
|
|
234
|
-
try:
|
|
235
|
-
response = self.app.api.leave_room(room_id)
|
|
236
|
-
|
|
237
|
-
if response.status_code == 200:
|
|
238
|
-
self.app.notify(f"Left '{escape(room_name)}'.", severity="success")
|
|
239
|
-
self._stomp_unsubscribe()
|
|
240
|
-
self.app.current_room_id = None
|
|
241
|
-
self.current_room_messages = []
|
|
242
|
-
self.app.file_cache.clear()
|
|
243
|
-
self._clear_to_empty_viewport()
|
|
244
|
-
self.refresh_rooms()
|
|
245
|
-
elif response.status_code == 404:
|
|
246
|
-
self.app.notify("Room not found.", severity="error")
|
|
247
|
-
else:
|
|
248
|
-
self.app.notify(
|
|
249
|
-
f"Failed to leave room ({response.status_code}): "
|
|
250
|
-
)
|
|
251
|
-
except Exception as e:
|
|
252
|
-
self.app.notify(f"Network error: {escape(str(e))}", severity="error")
|
|
253
|
-
|
|
254
|
-
### AUTO SWITCH ROOM ###
|
|
255
|
-
|
|
256
|
-
def _auto_switch_to_room(self, room_id: str | None) -> None:
|
|
257
|
-
if not room_id:
|
|
258
|
-
return
|
|
259
|
-
|
|
260
|
-
room = next((r for r in self.rooms if r["id"] == room_id), None)
|
|
261
|
-
if room:
|
|
262
|
-
self.switch_to_room(room_id)
|
|
263
|
-
else:
|
|
264
|
-
rooms = self.fetch_user_rooms()
|
|
265
|
-
self.rooms = rooms
|
|
266
|
-
self.call_after_refresh(self.switch_to_room, room_id)
|
|
267
|
-
|
|
268
|
-
### ROOM SWITCHING ###
|
|
269
|
-
|
|
270
|
-
def switch_to_room(self, room_id: str) -> None:
|
|
271
|
-
self._stomp_unsubscribe()
|
|
272
|
-
self.app.file_cache.clear()
|
|
273
|
-
self.current_room_messages = []
|
|
274
|
-
|
|
275
|
-
self.app.current_room_id = room_id
|
|
276
|
-
room = next((r for r in self.rooms if r["id"] == room_id), None)
|
|
277
|
-
|
|
278
|
-
if not room:
|
|
279
|
-
self.app.notify("Room details not found", severity="error")
|
|
280
|
-
return
|
|
281
|
-
|
|
282
|
-
self._set_active_room_button(room_id)
|
|
283
|
-
self._stomp_subscribe(room_id)
|
|
284
|
-
|
|
285
|
-
self.query_one("#chat-view").styles.display = "block"
|
|
286
|
-
self.query_one("#empty-view").styles.display = "none"
|
|
287
|
-
self.query_one("#chat_log").clear()
|
|
288
|
-
|
|
289
|
-
self.run_worker(lambda: self._fetch_messages_worker(room_id), thread=True)
|
|
290
|
-
|
|
291
|
-
def _fetch_messages_worker(self, room_id: str) -> None:
|
|
292
|
-
try:
|
|
293
|
-
response = self.app.api.fetch_messages(room_id)
|
|
294
|
-
|
|
295
|
-
if response.status_code == 200:
|
|
296
|
-
messages = response.json()
|
|
297
|
-
self.current_room_messages = messages
|
|
298
|
-
|
|
299
|
-
self.app.call_from_thread(self._render_messages, messages)
|
|
300
|
-
else:
|
|
301
|
-
self.app.call_from_thread(
|
|
302
|
-
self.app.notify,
|
|
303
|
-
f"Failed to load message history",
|
|
304
|
-
severity="error"
|
|
305
|
-
)
|
|
306
|
-
except Exception as e:
|
|
307
|
-
self.app.call_from_thread(
|
|
308
|
-
self.app.notify,
|
|
309
|
-
f"Error fetching messages: {escape(str(e))}",
|
|
310
|
-
severity="error"
|
|
311
|
-
)
|
|
312
|
-
|
|
313
|
-
def _render_messages(self, messages: list) -> None:
|
|
314
|
-
chat_log = self.query_one("#chat_log", RichLog)
|
|
315
|
-
for msg in messages:
|
|
316
|
-
self._write_message(chat_log, msg)
|
|
317
|
-
|
|
318
|
-
# --- STOMP Subscribe / Unsubscribe --- #
|
|
319
|
-
|
|
320
|
-
def _stomp_subscribe(self, room_id: str) -> None:
|
|
321
|
-
if self.app.stomp_conn and self.app.stomp_conn.sock:
|
|
322
|
-
self._current_subscription_id = room_id
|
|
323
|
-
subscribe_frame = (
|
|
324
|
-
f"SUBSCRIBE\n"
|
|
325
|
-
f"id:sub-{room_id}\n"
|
|
326
|
-
f"destination:/topic/room/{room_id}\n"
|
|
327
|
-
f"ack:auto\n"
|
|
328
|
-
f"Authorization:Bearer {self.app.access_token}\n\n\x00"
|
|
329
|
-
)
|
|
330
|
-
try:
|
|
331
|
-
self.app.stomp_conn.send(subscribe_frame)
|
|
332
|
-
except Exception as e:
|
|
333
|
-
self.app.notify(
|
|
334
|
-
f"Subscribe failed: {escape(str(e))}",
|
|
335
|
-
severity="error",
|
|
336
|
-
)
|
|
337
|
-
|
|
338
|
-
def _stomp_unsubscribe(self) -> None:
|
|
339
|
-
if self._current_subscription_id and self.app.stomp_conn and self.app.stomp_conn.sock:
|
|
340
|
-
unsubscribe_frame = (
|
|
341
|
-
f"UNSUBSCRIBE\n"
|
|
342
|
-
f"id:sub-{self._current_subscription_id}\n\n\x00"
|
|
343
|
-
)
|
|
344
|
-
try:
|
|
345
|
-
self.app.stomp_conn.send(unsubscribe_frame)
|
|
346
|
-
except Exception:
|
|
347
|
-
pass
|
|
348
|
-
finally:
|
|
349
|
-
self._current_subscription_id = None
|
|
350
|
-
|
|
351
|
-
def _set_active_room_button(self, room_id: str) -> None:
|
|
352
|
-
try:
|
|
353
|
-
for btn in self.query(".room-link").results(Button):
|
|
354
|
-
btn.remove_class("active")
|
|
355
|
-
self.query_one(f"#room_{room_id}").add_class("active")
|
|
356
|
-
except Exception:
|
|
357
|
-
pass
|
|
358
|
-
|
|
359
|
-
### MESSAGE DISPLAY ###
|
|
360
|
-
|
|
361
|
-
def _write_message(self, chat_log: RichLog, msg: dict) -> None:
|
|
362
|
-
sender = msg.get("sender", "System")
|
|
363
|
-
content = msg.get("content", "")
|
|
364
|
-
|
|
365
|
-
#Timestamp
|
|
366
|
-
ts_raw = msg.get("timeStamp") or msg.get("messageTime", "")
|
|
367
|
-
ts_str = ""
|
|
368
|
-
|
|
369
|
-
if ts_raw:
|
|
370
|
-
try:
|
|
371
|
-
dt = datetime.fromisoformat(str(ts_raw))
|
|
372
|
-
|
|
373
|
-
if dt.date() == datetime.now().date():
|
|
374
|
-
ts_str = f" [dim]{dt.strftime('%H:%M')}[/]"
|
|
375
|
-
else:
|
|
376
|
-
ts_str = f" [dim]{dt.strftime('%d %b, %H:%M')}[/]"
|
|
377
|
-
except Exception:
|
|
378
|
-
pass
|
|
379
|
-
|
|
380
|
-
# File message
|
|
381
|
-
if content.startswith("FILE:"):
|
|
382
|
-
try:
|
|
383
|
-
parts = content.split(":", 2)
|
|
384
|
-
if len(parts) >= 2:
|
|
385
|
-
filename = parts[1]
|
|
386
|
-
encoded_data = parts[2] if len(parts) > 2 else ""
|
|
387
|
-
|
|
388
|
-
if encoded_data:
|
|
389
|
-
self.app.file_cache[filename] = encoded_data
|
|
390
|
-
|
|
391
|
-
try:
|
|
392
|
-
size_kb = round(len(base64.b64decode(encoded_data)) / 1024, 1)
|
|
393
|
-
except Exception:
|
|
394
|
-
size_kb = 0.0
|
|
395
|
-
|
|
396
|
-
safe_filename = escape(filename)
|
|
397
|
-
display_content = (
|
|
398
|
-
f"📄 [bold]{safe_filename}[/] [dim]({size_kb} KB)[/] "
|
|
399
|
-
f"[@click=app.download_file('{safe_filename}')][underline cyan][ download ][/]"
|
|
400
|
-
)
|
|
401
|
-
else:
|
|
402
|
-
display_content = "📄 [italic][Malformed File Whisper][/]"
|
|
403
|
-
except Exception:
|
|
404
|
-
display_content = "📄 [italic][Error processing File Whisper][/]"
|
|
405
|
-
else:
|
|
406
|
-
display_content = escape(content)
|
|
407
|
-
|
|
408
|
-
safe_sender = escape(sender)
|
|
409
|
-
if sender == self.app.current_user:
|
|
410
|
-
chat_log.write(f"[bold cyan]You:[/]{ts_str} {display_content}")
|
|
411
|
-
else:
|
|
412
|
-
chat_log.write(f"[bold green]{safe_sender}:[/]{ts_str} {display_content}")
|
|
413
|
-
|
|
414
|
-
### CLEAR VIEWPORT ###
|
|
415
|
-
|
|
416
|
-
def _clear_to_empty_viewport(self) -> None:
|
|
417
|
-
try:
|
|
418
|
-
self.query_one("#chat-view").styles.display = "none"
|
|
419
|
-
self.query_one("#empty-view").styles.display = "block"
|
|
420
|
-
self.query_one("#chat_log").clear()
|
|
421
|
-
self.current_room_messages = []
|
|
422
|
-
except Exception:
|
|
423
|
-
pass
|
|
424
|
-
|
|
425
|
-
### DATA FETCHERS ###
|
|
426
|
-
|
|
427
|
-
def fetch_user_rooms(self) -> list:
|
|
428
|
-
try:
|
|
429
|
-
response = self.app.api.fetch_rooms()
|
|
430
|
-
if response.status_code == 200:
|
|
431
|
-
return response.json()
|
|
432
|
-
else:
|
|
433
|
-
self.app.notify(
|
|
434
|
-
f"Failed to load rooms ({response.status_code})",
|
|
435
|
-
severity="error",
|
|
436
|
-
)
|
|
437
|
-
except Exception as e:
|
|
438
|
-
self.app.notify(
|
|
439
|
-
f"Failed to load rooms {escape(str(e))}",
|
|
440
|
-
severity="error",
|
|
441
|
-
)
|
|
442
|
-
|
|
443
|
-
return []
|
|
444
|
-
|
|
445
|
-
### INPUT / SEND ###
|
|
446
|
-
|
|
447
|
-
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
448
|
-
raw = event.value.strip()
|
|
449
|
-
self.query_one('#chat_input').value = ""
|
|
450
|
-
|
|
451
|
-
if not raw:
|
|
452
|
-
return
|
|
453
|
-
|
|
454
|
-
if raw.lower() == "@help":
|
|
455
|
-
self._show_help()
|
|
456
|
-
return
|
|
457
|
-
|
|
458
|
-
if not self.app.current_room_id:
|
|
459
|
-
self.app.notify("Select a room first.", severity="warning")
|
|
460
|
-
return
|
|
461
|
-
|
|
462
|
-
if raw.startswith("@"):
|
|
463
|
-
self._handle_command(raw)
|
|
464
|
-
else:
|
|
465
|
-
self._send_message(raw)
|
|
466
|
-
|
|
467
|
-
def _show_help(self) -> None:
|
|
468
|
-
try:
|
|
469
|
-
chat_log = self.query_one("#chat_log", RichLog)
|
|
470
|
-
if self.query_one("#chat_log").styles.display == "none":
|
|
471
|
-
raise Exception("chat not visible")
|
|
472
|
-
except Exception:
|
|
473
|
-
self.app.notify(
|
|
474
|
-
"@copy:/path send file | @get:name download | @delete delete room | @leave leave room",
|
|
475
|
-
severity="information"
|
|
476
|
-
)
|
|
477
|
-
return
|
|
478
|
-
|
|
479
|
-
chat_log.write("[bold yellow]-- ShellWhisper Commands --[/]")
|
|
480
|
-
chat_log.write(" [cyan]@copy:/path/to/file[/] send any file to this room")
|
|
481
|
-
chat_log.write(" [cyan]@get:filename[/] download a file from room history")
|
|
482
|
-
chat_log.write(" [cyan]@save:filename[/] alias for @get")
|
|
483
|
-
chat_log.write(" [cyan]@delete[/] delete the current room")
|
|
484
|
-
chat_log.write(" [cyan]@leave[/] leave the current room")
|
|
485
|
-
chat_log.write(" [cyan]@help[/] show this message")
|
|
486
|
-
|
|
487
|
-
def _handle_command(self, raw: str) -> None:
|
|
488
|
-
chat_log = self.query_one("#chat_log", RichLog)
|
|
489
|
-
|
|
490
|
-
if raw.lower().startswith("@copy:"):
|
|
491
|
-
file_path = os.path.expanduser(raw[6:].strip())
|
|
492
|
-
|
|
493
|
-
if not file_path:
|
|
494
|
-
self.app.notify("Usage: @copy:/path/to/file", severity="warning")
|
|
495
|
-
return
|
|
496
|
-
if not os.path.isfile(file_path):
|
|
497
|
-
self.app.notify(f"File not found: {escape(file_path)}", severity="error")
|
|
498
|
-
return
|
|
499
|
-
|
|
500
|
-
try:
|
|
501
|
-
with open(file_path, "rb") as f:
|
|
502
|
-
binary_data = f.read()
|
|
503
|
-
|
|
504
|
-
if len(binary_data) > 5 * 1024 * 1024:
|
|
505
|
-
self.app.notify("File too large - 5 MB maximum.", severity="error")
|
|
506
|
-
return
|
|
507
|
-
|
|
508
|
-
encoded = base64.b64encode(binary_data).decode("utf-8")
|
|
509
|
-
filename = os.path.basename(file_path)
|
|
510
|
-
size_kb = round(len(binary_data) / 1024, 1)
|
|
511
|
-
|
|
512
|
-
chat_log.write(f"[dim]Sending:[/] [bold]{escape(filename)}[/] [dim]({size_kb} KB)[/]")
|
|
513
|
-
self._send_message(f"FILE:{filename}:{encoded}")
|
|
514
|
-
|
|
515
|
-
except Exception as e:
|
|
516
|
-
self.app.notify(f"@copy failed: {escape(str(e))}", severity="error")
|
|
517
|
-
|
|
518
|
-
elif raw.lower().startswith("@get:") or raw.lower().startswith("@save:"):
|
|
519
|
-
prefix_len = 5 if raw.lower().startswith("@get:") else 6
|
|
520
|
-
target = raw[prefix_len:].strip()
|
|
521
|
-
|
|
522
|
-
if not target:
|
|
523
|
-
self.app.notify("Usage: @get:filename", severity="warning")
|
|
524
|
-
return
|
|
525
|
-
|
|
526
|
-
if target in self.app.file_cache:
|
|
527
|
-
self.app.action_download_file(target)
|
|
528
|
-
return
|
|
529
|
-
|
|
530
|
-
found = False
|
|
531
|
-
for msg in reversed(self.current_room_messages):
|
|
532
|
-
c = msg.get("content", "")
|
|
533
|
-
|
|
534
|
-
if c.startswith(f"FILE:{target}:"):
|
|
535
|
-
_, filename, data = c.split(":", 2)
|
|
536
|
-
self.app.file_cache[filename] = data
|
|
537
|
-
self.app.action_download_file(filename)
|
|
538
|
-
found = True
|
|
539
|
-
break
|
|
540
|
-
|
|
541
|
-
if not found:
|
|
542
|
-
self.app.notify(f"File '{escape(target)}' not found in whispers", severity="warning")
|
|
543
|
-
|
|
544
|
-
elif raw.lower() == "@delete":
|
|
545
|
-
room_id = self.app.current_room_id
|
|
546
|
-
if not room_id:
|
|
547
|
-
self.app.notify("Select an active room first.", severity="warning")
|
|
548
|
-
return
|
|
549
|
-
|
|
550
|
-
room = next((r for r in self.rooms if r["id"] == self.app.current_room_id), None)
|
|
551
|
-
if not room:
|
|
552
|
-
return
|
|
553
|
-
|
|
554
|
-
if room.get("type") == "PRIVATE":
|
|
555
|
-
self._do_delete_room(room_id, "")
|
|
556
|
-
else:
|
|
557
|
-
self._pending_room_data = {
|
|
558
|
-
"name": room["roomName"],
|
|
559
|
-
"action": "chat_command_delete",
|
|
560
|
-
"id": room_id
|
|
561
|
-
}
|
|
562
|
-
self.app.push_screen(
|
|
563
|
-
SecurityScreen(action="chat_command_delete", room_name=room["roomName"]),
|
|
564
|
-
self._on_chat_command_security_dismissed,
|
|
565
|
-
)
|
|
566
|
-
|
|
567
|
-
elif raw.lower() == "@leave":
|
|
568
|
-
room_id = self.app.current_room_id
|
|
569
|
-
if not room_id:
|
|
570
|
-
self.app.notify("No active room selected.", severity="warning")
|
|
571
|
-
return
|
|
572
|
-
self._do_leave_room(room_id)
|
|
573
|
-
|
|
574
|
-
elif raw.lower() == "@help":
|
|
575
|
-
self._show_help()
|
|
576
|
-
|
|
577
|
-
else:
|
|
578
|
-
self.app.notify(
|
|
579
|
-
f"Unknown command '{escape(raw)}'. Type @help for available commands.",
|
|
580
|
-
severity="warning",
|
|
581
|
-
)
|
|
582
|
-
|
|
583
|
-
def _on_delete_security_dismissed(self, security_key: str | None) -> None:
|
|
584
|
-
if not security_key or not self._pending_room_data:
|
|
585
|
-
return
|
|
586
|
-
|
|
587
|
-
room_id = self._pending_room_data.get("id")
|
|
588
|
-
self._pending_room_data = None
|
|
589
|
-
|
|
590
|
-
if room_id:
|
|
591
|
-
self._do_delete_room(room_id, security_key)
|
|
592
|
-
|
|
593
|
-
def _on_chat_command_security_dismissed(self, security_key: str | None) -> None:
|
|
594
|
-
if not security_key or not self._pending_room_data:
|
|
595
|
-
return
|
|
596
|
-
|
|
597
|
-
action = self._pending_room_data["action"]
|
|
598
|
-
room_id = self._pending_room_data.get("id")
|
|
599
|
-
self._pending_room_data = None
|
|
600
|
-
|
|
601
|
-
if action == "chat_command_delete" and room_id:
|
|
602
|
-
self._do_delete_room(room_id, security_key)
|
|
603
|
-
|
|
604
|
-
def _send_message(self, message_text: str) -> None:
|
|
605
|
-
payload = {
|
|
606
|
-
"sender": self.app.current_user,
|
|
607
|
-
"content": message_text,
|
|
608
|
-
"roomId": self.app.current_room_id,
|
|
609
|
-
"messageTime": datetime.now().isoformat(),
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
payload_str = json.dumps(payload)
|
|
613
|
-
byte_length = len(payload_str.encode('utf-8'))
|
|
614
|
-
|
|
615
|
-
frame = (
|
|
616
|
-
f"SEND\n"
|
|
617
|
-
f"destination:/app/sendMessage\n"
|
|
618
|
-
f"content-type:application/json\n"
|
|
619
|
-
f"content-length:{byte_length}\n\n"
|
|
620
|
-
f"{payload_str}\x00"
|
|
621
|
-
)
|
|
622
|
-
|
|
623
|
-
if self.app.stomp_conn and self.app.stomp_conn.sock:
|
|
624
|
-
try:
|
|
625
|
-
self.app.stomp_conn.send(frame)
|
|
626
|
-
except Exception as e:
|
|
627
|
-
self.app.notify(
|
|
628
|
-
f"Failed to send message: {escape(str(e))}",
|
|
629
|
-
severity="error"
|
|
630
|
-
)
|
|
631
|
-
else:
|
|
632
|
-
self.app.notify(
|
|
633
|
-
f"Not connected - message not sent.",
|
|
634
|
-
severity="error"
|
|
635
|
-
)
|
|
636
|
-
|
|
637
|
-
### INCOMING REAL-TIME MESSAGES ###
|
|
638
|
-
|
|
639
|
-
def on_new_whisper_received(self, event: NewWhisperReceived) -> None:
|
|
640
|
-
data = event.data
|
|
641
|
-
self.current_room_messages.append(data)
|
|
642
|
-
chat_log = self.query_one("#chat_log", RichLog)
|
|
643
|
-
self._write_message(chat_log, data)
|
|
644
|
-
|
|
645
|
-
### LOGOUT ###
|
|
646
|
-
|
|
647
|
-
def action_logout(self) -> None:
|
|
648
|
-
self.logout_process()
|
|
649
|
-
|
|
650
|
-
def logout_process(self) -> None:
|
|
651
|
-
from src.screens.login import LoginScreen
|
|
652
|
-
|
|
653
|
-
self._stomp_unsubscribe()
|
|
654
|
-
self.app._disconnect_websocket()
|
|
655
|
-
self.app.access_token = None
|
|
656
|
-
self.app.current_user = None
|
|
657
|
-
self.app.current_room_id = None
|
|
658
|
-
self.app.file_cache.clear()
|
|
659
|
-
|
|
660
|
-
self.app.notify("Logged out successfully", severity="information")
|
|
661
|
-
self.app.switch_screen(LoginScreen())
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
from textual.app import ComposeResult
|
|
2
|
-
from textual.screen import Screen
|
|
3
|
-
from textual.widgets import Header, Footer, Input, Button, Label
|
|
4
|
-
from textual.containers import Vertical, Center, Middle
|
|
5
|
-
|
|
6
|
-
class ForgotPasswordScreen(Screen):
|
|
7
|
-
BINDINGS = [("escape", "app.pop_screen", "Back to Login")]
|
|
8
|
-
|
|
9
|
-
def compose(self) -> ComposeResult:
|
|
10
|
-
yield Header()
|
|
11
|
-
|
|
12
|
-
with Middle():
|
|
13
|
-
with Center():
|
|
14
|
-
with Vertical(id="login-form"):
|
|
15
|
-
yield Label("[bold yellow]Reset Password[/]", id="title")
|
|
16
|
-
yield Label("Enter your registered email address:", classes="hint")
|
|
17
|
-
yield Input(placeholder="Email", id="reset_email")
|
|
18
|
-
yield Button("Send Reset Code", variant="primary", id="send_code_btn")
|
|
19
|
-
yield Button("Back to Login", variant="default", id="back_btn")
|
|
20
|
-
yield Footer()
|
|
21
|
-
|
|
22
|
-
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
23
|
-
if event.button.id == "back_btn":
|
|
24
|
-
self.dismiss()
|
|
25
|
-
elif event.button.id == "send_code_btn":
|
|
26
|
-
email = self.query_one("#reset_email").value.strip()
|
|
27
|
-
if not email:
|
|
28
|
-
self.app.notify("Email is required", severity="error")
|
|
29
|
-
return
|
|
30
|
-
|
|
31
|
-
try:
|
|
32
|
-
self.app.api.request_password_reset(email)
|
|
33
|
-
self.app.notify("Reset code sent! Check you email.", severity="information")
|
|
34
|
-
self.app.switch_screen(ConfirmResetScreen(email=email))
|
|
35
|
-
except Exception:
|
|
36
|
-
self.app.notify("Failed to contact server.", severity="error")
|
|
37
|
-
|
|
38
|
-
class ConfirmResetScreen(Screen):
|
|
39
|
-
BINDINGS = [("escape", "app.pop_screen", "Back to Login")]
|
|
40
|
-
|
|
41
|
-
def __init__(self, email: str = ""):
|
|
42
|
-
super().__init__()
|
|
43
|
-
self.target_email = email
|
|
44
|
-
|
|
45
|
-
def compose(self) -> ComposeResult:
|
|
46
|
-
yield Header()
|
|
47
|
-
|
|
48
|
-
with Middle():
|
|
49
|
-
with Center():
|
|
50
|
-
with Vertical(id="login-form"):
|
|
51
|
-
yield Label("[bold green]Confirm New Password[/]", id="title")
|
|
52
|
-
yield Label(f"Code sent to {self.target_email}", classes="hint")
|
|
53
|
-
yield Input(placeholder="8-Character Code", id="reset_code")
|
|
54
|
-
yield Input(placeholder="New Password", password=True, id="new_pass")
|
|
55
|
-
yield Button("Update Password", variant="success", id="update_btn")
|
|
56
|
-
yield Button("Cancel", variant="error", id="cancel_btn")
|
|
57
|
-
yield Footer()
|
|
58
|
-
|
|
59
|
-
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
60
|
-
from src.screens.login import LoginScreen
|
|
61
|
-
if event.button.id == "cancel_btn":
|
|
62
|
-
self.app.switch_screen(LoginScreen())
|
|
63
|
-
elif event.button.id == "update_btn":
|
|
64
|
-
code = self.query_one("#reset_code").value.strip()
|
|
65
|
-
new_pass = self.query_one("#new_pass").value.strip()
|
|
66
|
-
|
|
67
|
-
if not code or not new_pass:
|
|
68
|
-
self.app.notify("All fields are required", severity="error")
|
|
69
|
-
return
|
|
70
|
-
|
|
71
|
-
try:
|
|
72
|
-
res = self.app.api.confirm_password_reset(self.target_email, code, new_pass)
|
|
73
|
-
if res.status_code == 200:
|
|
74
|
-
self.app.notify("Password updated successfully! Please log in.", severity="success")
|
|
75
|
-
self.app.switch_screen(LoginScreen())
|
|
76
|
-
else:
|
|
77
|
-
self.app.notify("Invalid or expired code.", severity="error")
|
|
78
|
-
except Exception:
|
|
79
|
-
self.app.notify("Failed to contact server", severity="error")
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
from textual.screen import ModalScreen
|
|
2
|
-
from textual.containers import Center, Grid, Vertical
|
|
3
|
-
from textual.widgets import Footer, Input, Label, Button
|
|
4
|
-
|
|
5
|
-
class JoinRoomScreen(ModalScreen):
|
|
6
|
-
|
|
7
|
-
def compose(self):
|
|
8
|
-
with Grid(id="join-dialog"):
|
|
9
|
-
yield Label("Enter Room ID to Join", id="join-label")
|
|
10
|
-
yield Input(placeholder="Room ID", id="room-id-input")
|
|
11
|
-
yield Button("Join Room", variant="primary", id="confirm-join")
|
|
12
|
-
yield Button("Cancel", variant="error", id="cancel-join")
|
|
13
|
-
|
|
14
|
-
def on_button_pressed(self, event):
|
|
15
|
-
if event.button.id == "confirm-join":
|
|
16
|
-
room_id = self.query_one("#room-id-input").value
|
|
17
|
-
|
|
18
|
-
self.dismiss(room_id)
|
|
19
|
-
else:
|
|
20
|
-
self.dismiss(None)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
from textual.app import ComposeResult
|
|
2
|
-
from textual.screen import Screen
|
|
3
|
-
from textual.widgets import Header, Footer, Input, Button, Label
|
|
4
|
-
from textual.containers import Vertical, Center, Middle
|
|
5
|
-
|
|
6
|
-
from src.screens.chat_screen import ChatScreen
|
|
7
|
-
|
|
8
|
-
import requests
|
|
9
|
-
|
|
10
|
-
class LoginScreen(Screen):
|
|
11
|
-
BINDINGS = [
|
|
12
|
-
("ctrl+q", "quit", "Quit"),
|
|
13
|
-
("ctrl+d", "toggle_dark", "Toggle dark mode"),
|
|
14
|
-
]
|
|
15
|
-
|
|
16
|
-
def compose(self) -> ComposeResult:
|
|
17
|
-
yield Header()
|
|
18
|
-
|
|
19
|
-
with Middle():
|
|
20
|
-
with Center():
|
|
21
|
-
with Vertical(id="login-form"):
|
|
22
|
-
yield Label("[bold cyan] Welcome to ShellWhisper[/]", id="title")
|
|
23
|
-
yield Input(placeholder="Username", id="username")
|
|
24
|
-
yield Input(placeholder="Password", password=True, id="password")
|
|
25
|
-
yield Button("Login", variant="success", id="login_btn")
|
|
26
|
-
yield Button("Need an account? Sign up", variant="default", id="to_signup")
|
|
27
|
-
yield Button("Forgot Password?", variant="default", id="forgot_pass_btn")
|
|
28
|
-
yield Footer()
|
|
29
|
-
|
|
30
|
-
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
31
|
-
if event.button.id == "login_btn":
|
|
32
|
-
username = self.query_one("#username").value.strip()
|
|
33
|
-
password = self.query_one("#password").value.strip()
|
|
34
|
-
|
|
35
|
-
try:
|
|
36
|
-
response = self.app.api.login(username, password)
|
|
37
|
-
|
|
38
|
-
if response.status_code == 200:
|
|
39
|
-
data = response.json()
|
|
40
|
-
self.app.access_token = data.get("token")
|
|
41
|
-
|
|
42
|
-
self.app.refresh_token = data.get("refreshToken")
|
|
43
|
-
|
|
44
|
-
self.app.current_user = username
|
|
45
|
-
self.app.notify(f"Welcome, {username}!", severity="success")
|
|
46
|
-
self.app.switch_screen(ChatScreen())
|
|
47
|
-
|
|
48
|
-
elif response.status_code == 401:
|
|
49
|
-
self.app.notify("Invalid username or password", severity="error")
|
|
50
|
-
|
|
51
|
-
else:
|
|
52
|
-
self.app.notify(f"Server Error: {response.status_code}", severity="error")
|
|
53
|
-
|
|
54
|
-
except Exception:
|
|
55
|
-
self.app.notify("Could not connect to ShellWhisper server", severity="error")
|
|
56
|
-
|
|
57
|
-
elif event.button.id == "to_signup":
|
|
58
|
-
self.app.push_screen(SignupScreen())
|
|
59
|
-
|
|
60
|
-
elif event.button.id == "forgot_pass_btn":
|
|
61
|
-
from src.screens.forgot_password import ForgotPasswordScreen
|
|
62
|
-
self.app.push_screen(ForgotPasswordScreen())
|
|
63
|
-
|
|
64
|
-
class SignupScreen(Screen):
|
|
65
|
-
BINDINGS = [("escape", "app.pop_screen", "Back to Login")]
|
|
66
|
-
|
|
67
|
-
def compose(self) -> ComposeResult:
|
|
68
|
-
yield Header()
|
|
69
|
-
with Center():
|
|
70
|
-
with Vertical(id="signup-form"):
|
|
71
|
-
yield Label("[bold]Create Account[/]")
|
|
72
|
-
yield Input(placeholder="Username", id="signup_user")
|
|
73
|
-
yield Input(placeholder="Email", id="signup_email")
|
|
74
|
-
yield Input(placeholder="Password", password=True, id="signup_pass")
|
|
75
|
-
yield Button("Register", variant="primary", id="register_btn")
|
|
76
|
-
yield Button("Back to Login", variant="default", id="back_btn")
|
|
77
|
-
yield Footer()
|
|
78
|
-
|
|
79
|
-
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
80
|
-
if event.button.id == "back_btn":
|
|
81
|
-
self.dismiss()
|
|
82
|
-
|
|
83
|
-
elif event.button.id == "register_btn":
|
|
84
|
-
username = self.query_one("#signup_user").value.strip()
|
|
85
|
-
email = self.query_one("#signup_email").value.strip()
|
|
86
|
-
password = self.query_one("#signup_pass").value.strip()
|
|
87
|
-
|
|
88
|
-
if not username or not password:
|
|
89
|
-
self.app.notify("Username and Password required!", severity="error")
|
|
90
|
-
return
|
|
91
|
-
|
|
92
|
-
try:
|
|
93
|
-
response = self.app.api.signup(username, email, password)
|
|
94
|
-
|
|
95
|
-
if response.status_code == 200:
|
|
96
|
-
self.app.notify("Account created! Please login.", severity="information")
|
|
97
|
-
self.dismiss()
|
|
98
|
-
else:
|
|
99
|
-
error_msg = response.text or "Signup failed !"
|
|
100
|
-
self.app.notify(f"Error: {error_msg}", severity="error")
|
|
101
|
-
|
|
102
|
-
except Exception:
|
|
103
|
-
self.app.notify("Backend server is not running!", severity="error")
|
|
104
|
-
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
from textual.screen import ModalScreen
|
|
2
|
-
from textual.containers import Grid, Vertical
|
|
3
|
-
from textual.widgets import Label, Input, Button
|
|
4
|
-
|
|
5
|
-
class PrivateWhisperPromptScreen(ModalScreen):
|
|
6
|
-
def compose(self):
|
|
7
|
-
with Vertical(id="room-action-dialog"):
|
|
8
|
-
yield Label("Start a Private Whisper", id="action-label")
|
|
9
|
-
yield Input(placeholder="Enter target username...", id="room-input")
|
|
10
|
-
|
|
11
|
-
with Grid(id="action-button-grid"):
|
|
12
|
-
yield Button("Start Chat", variant="success", id="start_btn")
|
|
13
|
-
yield Button("Cancel", variant="error", id="cancel_btn")
|
|
14
|
-
|
|
15
|
-
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
16
|
-
if event.button.id == "cancel_btn":
|
|
17
|
-
self.dismiss(None)
|
|
18
|
-
return
|
|
19
|
-
|
|
20
|
-
target_user = self.query_one("#room-input").value.strip()
|
|
21
|
-
|
|
22
|
-
if not target_user:
|
|
23
|
-
self.app.notify("Please enter a username.", severity="error")
|
|
24
|
-
return
|
|
25
|
-
|
|
26
|
-
self.dismiss(target_user)
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
from textual.screen import ModalScreen
|
|
2
|
-
from textual.containers import Grid, Vertical
|
|
3
|
-
from textual.widgets import Label, Input, Button
|
|
4
|
-
|
|
5
|
-
class RoomActionScreen(ModalScreen):
|
|
6
|
-
def compose(self):
|
|
7
|
-
with Vertical(id="room-action-dialog"):
|
|
8
|
-
yield Label("Room Actions", id="action-label")
|
|
9
|
-
yield Input(placeholder="Enter room name...", id="room-input")
|
|
10
|
-
|
|
11
|
-
with Grid(id="action-buttons-grid"):
|
|
12
|
-
yield Button("Join Room", variant="primary", id="join_btn")
|
|
13
|
-
yield Button("Create Room", variant="success", id="create_btn")
|
|
14
|
-
|
|
15
|
-
yield Button("Cancel", variant="error", id="cancel_btn")
|
|
16
|
-
|
|
17
|
-
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
18
|
-
if event.button.id == "cancel_btn":
|
|
19
|
-
self.dismiss(None)
|
|
20
|
-
return
|
|
21
|
-
|
|
22
|
-
room_name = self.query_one("#room-input").value.strip()
|
|
23
|
-
|
|
24
|
-
if not room_name:
|
|
25
|
-
self.app.notify("Please enter a room name.", severity="error")
|
|
26
|
-
return
|
|
27
|
-
|
|
28
|
-
if not room_name.replace("-", "").replace("_", "").isalnum():
|
|
29
|
-
self.app.notify(
|
|
30
|
-
"Room name can only contain letters, numbers, hyphens and underscores.",
|
|
31
|
-
severity="error",
|
|
32
|
-
)
|
|
33
|
-
return
|
|
34
|
-
|
|
35
|
-
self.dismiss({"name": room_name, "action": event.button.id})
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
from textual.screen import ModalScreen
|
|
2
|
-
from textual.containers import Vertical
|
|
3
|
-
from textual.widgets import Label, Input, Button
|
|
4
|
-
|
|
5
|
-
class SecurityScreen(ModalScreen):
|
|
6
|
-
|
|
7
|
-
def __init__(self, action: str = "authenticate", room_name: str = "") -> None:
|
|
8
|
-
super().__init__()
|
|
9
|
-
self.action = action
|
|
10
|
-
self.room_name = room_name
|
|
11
|
-
|
|
12
|
-
def compose(self):
|
|
13
|
-
if self.action == "create_btn":
|
|
14
|
-
title = f"Set Security Key for '{self.room_name}'"
|
|
15
|
-
hint = "Others will need key to join."
|
|
16
|
-
btn_label = "Create Room"
|
|
17
|
-
btn_variant = "success"
|
|
18
|
-
elif self.action == "chat_command_delete":
|
|
19
|
-
title = f"Confirm Deletion of '{self.room_name}'"
|
|
20
|
-
hint = "Enter the room security key to permanently delete this room."
|
|
21
|
-
btn_label = "Delete Room"
|
|
22
|
-
btn_variant = "error"
|
|
23
|
-
else:
|
|
24
|
-
title = f"Enter Security Key for '{self.room_name}'"
|
|
25
|
-
hint = "The key set by the room creator."
|
|
26
|
-
btn_label = "Join Room"
|
|
27
|
-
btn_variant = "primary"
|
|
28
|
-
|
|
29
|
-
with Vertical(id="security-dialog"):
|
|
30
|
-
yield Label(title, id="security-label")
|
|
31
|
-
yield Label(f"[dim]{hint}[/]", id="security-hint")
|
|
32
|
-
yield Input(placeholder="Secret key...", password=True, id="security-input")
|
|
33
|
-
yield Button(btn_label, variant=btn_variant, id="auth_btn")
|
|
34
|
-
yield Button("Cancel", variant="error", id="cancel_btn")
|
|
35
|
-
|
|
36
|
-
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
37
|
-
if event.button.id == "cancel_btn":
|
|
38
|
-
self.dismiss(None)
|
|
39
|
-
elif event.button.id == "auth_btn":
|
|
40
|
-
val = self.query_one("#security-input").value.strip()
|
|
41
|
-
if not val:
|
|
42
|
-
self.app.notify("Security key cannot be empty.", severity="error")
|
|
43
|
-
return
|
|
44
|
-
self.dismiss(val)
|
|
45
|
-
|
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
import requests
|
|
2
|
-
import os
|
|
3
|
-
|
|
4
|
-
class APIClient:
|
|
5
|
-
# BASE_URL = "http://localhost:8080/api/v1"
|
|
6
|
-
# BASE_URL = os.getenv(
|
|
7
|
-
# "SHELLWHISPER_API_URL",
|
|
8
|
-
# "https://shellwhisper-server.onrender.com/api/v1"
|
|
9
|
-
# )
|
|
10
|
-
# WS_URL = os.getenv(
|
|
11
|
-
# "SHELLWHISPER_WS_URL",
|
|
12
|
-
# "wss://shellwhisper-server.onrender.com/chat/websocket"
|
|
13
|
-
# )
|
|
14
|
-
|
|
15
|
-
BASE_URL = "https://shellwhisper-server.onrender.com/api/v1"
|
|
16
|
-
WS_URL = "wss://shellwhisper-server.onrender.com/chat/websocket"
|
|
17
|
-
|
|
18
|
-
def __init__(self, app):
|
|
19
|
-
self.app = app
|
|
20
|
-
|
|
21
|
-
def _get_headers(self):
|
|
22
|
-
return {"Authorization": f"Bearer {self.app.access_token}"}
|
|
23
|
-
|
|
24
|
-
def _make_auth_request(self, method, endpoint, **kwargs):
|
|
25
|
-
url = f"{self.BASE_URL}{endpoint}"
|
|
26
|
-
kwargs["headers"] = self._get_headers()
|
|
27
|
-
kwargs.setdefault("timeout", 5)
|
|
28
|
-
|
|
29
|
-
response = requests.request(method, url, **kwargs)
|
|
30
|
-
|
|
31
|
-
if response.status_code == 401 and getattr(self.app, 'refresh_token', None):
|
|
32
|
-
refresh_res = requests.post(
|
|
33
|
-
f"{self.BASE_URL}/auth/refresh",
|
|
34
|
-
json={"refreshToken": self.app.refresh_token},
|
|
35
|
-
timeout=5
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
if refresh_res.status_code == 200:
|
|
39
|
-
data = refresh_res.json()
|
|
40
|
-
self.app.access_token = data.get("token")
|
|
41
|
-
|
|
42
|
-
kwargs["headers"] = self._get_headers()
|
|
43
|
-
|
|
44
|
-
return requests.request(method, url, **kwargs)
|
|
45
|
-
else:
|
|
46
|
-
self.app.call_from_thread(self.app.action_logout)
|
|
47
|
-
|
|
48
|
-
return response
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def fetch_rooms(self):
|
|
52
|
-
return self._make_auth_request(
|
|
53
|
-
"GET",
|
|
54
|
-
"/room/my-rooms",
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
def fetch_messages(self, room_id: str):
|
|
58
|
-
return self._make_auth_request(
|
|
59
|
-
"GET",
|
|
60
|
-
f"/messages/{room_id}"
|
|
61
|
-
)
|
|
62
|
-
|
|
63
|
-
def create_room(self, room_name: str, security_string: str):
|
|
64
|
-
return self._make_auth_request(
|
|
65
|
-
"POST",
|
|
66
|
-
"/room/new",
|
|
67
|
-
json={"roomName": room_name, "rawSecurityString": security_string}
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
def join_room(self, room_name: str, security_string: str):
|
|
71
|
-
return self._make_auth_request(
|
|
72
|
-
"POST",
|
|
73
|
-
"/room/join",
|
|
74
|
-
json={"roomName": room_name, "rawSecurityString": security_string}
|
|
75
|
-
)
|
|
76
|
-
|
|
77
|
-
def delete_room(self, room_id: str, security_key: str = ""):
|
|
78
|
-
return self._make_auth_request(
|
|
79
|
-
"DELETE",
|
|
80
|
-
f"/room/delete/{room_id}",
|
|
81
|
-
params={"securityKey": security_key}
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
def leave_room(self, room_id: str):
|
|
85
|
-
return self._make_auth_request(
|
|
86
|
-
"DELETE",
|
|
87
|
-
f"/room/{room_id}/leave",
|
|
88
|
-
timeout=10
|
|
89
|
-
)
|
|
90
|
-
|
|
91
|
-
def start_private_chat(self, target_username: str):
|
|
92
|
-
return self._make_auth_request(
|
|
93
|
-
"POST",
|
|
94
|
-
f"/room/private/{target_username}"
|
|
95
|
-
)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def login(self, username: str, password: str):
|
|
99
|
-
return requests.post(
|
|
100
|
-
f"{self.BASE_URL}/auth/login",
|
|
101
|
-
json={"username": username, "password": password},
|
|
102
|
-
timeout=45,
|
|
103
|
-
)
|
|
104
|
-
|
|
105
|
-
def signup(self, username: str, email: str, password: str):
|
|
106
|
-
return requests.post(
|
|
107
|
-
f"{self.BASE_URL}/auth/signup",
|
|
108
|
-
json={"username": username, "email": email, "password": password},
|
|
109
|
-
timeout=45,
|
|
110
|
-
)
|
|
111
|
-
|
|
112
|
-
def logout_backend(self):
|
|
113
|
-
if getattr(self.app, 'refresh_token', None):
|
|
114
|
-
requests.post(
|
|
115
|
-
f"{self.BASE_URL}/auth/logout",
|
|
116
|
-
json={"refreshToken": self.app.refresh_token},
|
|
117
|
-
headers=self._get_headers(),
|
|
118
|
-
timeout=5
|
|
119
|
-
)
|
|
120
|
-
|
|
121
|
-
def request_password_reset(self, email: str):
|
|
122
|
-
return requests.post(
|
|
123
|
-
f"{self.BASE_URL}/auth/forgot-password",
|
|
124
|
-
json={"email": email},
|
|
125
|
-
timeout=5,
|
|
126
|
-
)
|
|
127
|
-
|
|
128
|
-
def confirm_password_reset(self, email: str, token: str, new_password: str):
|
|
129
|
-
return requests.post(
|
|
130
|
-
f"{self.BASE_URL}/auth/reset-password",
|
|
131
|
-
json={"email": email, "token": token, "newPassword": new_password},
|
|
132
|
-
timeout=5,
|
|
133
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{shellwhisper_cli-1.0.2 → shellwhisper_cli-1.0.4}/shellwhisper_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{shellwhisper_cli-1.0.2 → shellwhisper_cli-1.0.4}/shellwhisper_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|