5
5
import re
6
6
import time
7
7
8
- from textual .app import App
9
- from textual .widgets import Header , Footer , Input , Log , DataTable , Static
8
+ from textual .app import App , ComposeResult
10
9
from textual .binding import Binding
11
- from textual .containers import Horizontal , Vertical
10
+ from textual .containers import Horizontal , Vertical , Grid
11
+ from textual .screen import ModalScreen
12
+ from textual .widgets import Header , Footer , TabbedContent , TabPane , Input , Log , DataTable , Static , Button , Label
12
13
13
14
import RNS
14
15
16
+ ADDRESS_BOOK_FILE = "address_book.json"
17
+
15
18
class AnnounceHandler :
16
19
def __init__ (self , app , aspect_filter = None ):
17
20
self .app = app
@@ -44,11 +47,48 @@ def received_announce(self, destination_hash, announced_identity, app_data):
44
47
# f"[ANNOUNCE] {timestamp} - {display_name} ({dest_hash_display})"
45
48
#)
46
49
47
- # Call the app's `on_announce` function to update the table
48
50
self .app .on_announce (destination_hash , announced_identity , app_data )
49
51
52
+ class ServerDetailScreen (ModalScreen ):
53
+ """Screen to display server details."""
54
+
55
+ def __init__ (self , server_name , destination_hash , timestamp , on_connect , saved_in_address_book ):
56
+ super ().__init__ ()
57
+ self .server_name = server_name
58
+ self .destination_hash = destination_hash
59
+ self .timestamp = timestamp
60
+ self .on_connect = on_connect
61
+ self .saved_in_address_book = saved_in_address_book
62
+
63
+ def compose (self ) -> ComposeResult :
64
+ yield Grid (
65
+ Label (
66
+ f"Server Name: { self .server_name } \n Destination Hash: { self .destination_hash } \n Last Heard: { self .timestamp } " ,
67
+ id = "server_details"
68
+ ),
69
+ Button ("Connect" , id = "connect" , variant = "success" , classes = "button" ),
70
+ Button (
71
+ "Remove from Address Book" if self .saved_in_address_book else "Save to Address Book" ,
72
+ id = "toggle_address_book" ,
73
+ variant = "primary" ,
74
+ classes = "button" ,
75
+ ),
76
+ Button ("Close" , id = "close" , variant = "default" , classes = "button" ),
77
+ id = "server_details_dialog" ,
78
+ )
79
+
80
+ def on_button_pressed (self , event : Button .Pressed ) -> None :
81
+ if event .button .id == "connect" :
82
+ asyncio .create_task (self .on_connect (self .destination_hash ))
83
+ self .app .pop_screen ()
84
+ elif event .button .id == "toggle_address_book" :
85
+ self .app .pop_screen ()
86
+ self .app .toggle_address_book (self .destination_hash , self .saved_in_address_book )
87
+ elif event .button .id == "close" :
88
+ self .app .pop_screen ()
89
+
50
90
class RetiBBSClient (App ):
51
- CSS_PATH = "app.css "
91
+ CSS_PATH = "app.tcss "
52
92
53
93
BINDINGS = [
54
94
Binding (key = "q" , action = "quit" , description = "Quit the app" ),
@@ -62,32 +102,73 @@ class RetiBBSClient(App):
62
102
63
103
def __init__ (self , server_hexhash = None ):
64
104
super ().__init__ ()
105
+ self ._deferred_debug_log = []
65
106
self .server_hexhash = server_hexhash
66
107
self .client_identity = None
67
108
self .link = None
68
109
self .servers = {}
110
+ self .address_book = {}
111
+ self .active_tab = "servers"
112
+ self .server_list_update_pending = False
69
113
70
- def compose (self ):
114
+ def compose (self ) -> ComposeResult :
71
115
yield Header ()
116
+
117
+ server_tab = TabPane (title = "Servers" , id = "servers" )
118
+ server_tab .compose_add_child (
119
+ DataTable (id = "server_list" , classes = "server-list" , cursor_type = "row" )
120
+ )
121
+
122
+ address_tab = TabPane (title = "Address Book" , id = "address_book" )
123
+ address_tab .compose_add_child (
124
+ DataTable (id = "address_book" , classes = "address-book" , cursor_type = "row" )
125
+ )
126
+
127
+ tabs = TabbedContent (id = "tabs" , classes = "tabs" )
128
+ tabs .compose_add_child (server_tab )
129
+ tabs .compose_add_child (address_tab )
130
+ tabs .default_tab = "Servers"
131
+
72
132
yield Horizontal (
73
133
Vertical (
74
134
Log (id = "main_log" ),
75
135
Input (placeholder = "Enter command..." , id = "command_input" ),
76
- id = "left_panel" ,
136
+ id = "left_panel"
77
137
),
78
138
Vertical (
79
- Static ("Servers" , id = "server_list_title" ),
80
- DataTable (id = "server_list" , classes = "server-list" ),
139
+ tabs ,
81
140
Log (id = "debug_log" , classes = "debug-log" ),
82
- id = "right_panel" ,
141
+ id = "right_panel"
83
142
),
84
- id = "main_body" ,
143
+ id = "main_body"
85
144
)
86
145
yield Footer ()
146
+
147
+ def load_address_book (self ):
148
+ if os .path .exists (ADDRESS_BOOK_FILE ):
149
+ try :
150
+ with open (ADDRESS_BOOK_FILE , "r" ) as file :
151
+ address_book = json .load (file )
152
+ self ._deferred_debug_log .append (f"[DEBUG] Address book loaded: { address_book } " )
153
+ return address_book
154
+ except Exception as e :
155
+ self ._deferred_debug_log .append (f"[DEBUG] Error loading address book: { e } " )
156
+ return {}
157
+
158
+ def save_address_book (self ):
159
+ with open (ADDRESS_BOOK_FILE , "w" ) as file :
160
+ json .dump (self .address_book , file , indent = 4 )
87
161
88
162
def write_debug_log (self , message ):
89
- debug_log = self .query_one ("#debug_log" , Log )
90
- debug_log .write_line (message )
163
+ try :
164
+ # Attempt to find the debug log and write the message
165
+ debug_log = self .query_one ("#debug_log" , Log )
166
+ debug_log .write_line (message )
167
+ except Exception :
168
+ # Defer the message if the debug log is not found
169
+ if not hasattr (self , "_deferred_debug_log" ):
170
+ self ._deferred_debug_log = []
171
+ self ._deferred_debug_log .append (message )
91
172
92
173
def write_log (self , message ):
93
174
log = self .query_one ("#main_log" , Log )
@@ -138,7 +219,28 @@ async def on_mount(self):
138
219
self .title = "- RetiBBS Client -"
139
220
140
221
server_list = self .query_one ("#server_list" , DataTable )
141
- server_list .add_columns ("Server Name" , "Destination Hash" )
222
+ if not server_list .columns :
223
+ server_list .add_columns ("Server Name" , "Destination Hash" )
224
+
225
+ address_book = self .query_one ("#address_book" , DataTable )
226
+ if not address_book .columns :
227
+ address_book .add_columns ("Server Name" , "Destination Hash" )
228
+
229
+ server_list .visible = True
230
+ address_book .visible = True
231
+
232
+ # Log deferred errors (if any)
233
+ if hasattr (self , "_deferred_debug_log" ):
234
+ for message in self ._deferred_debug_log :
235
+ try :
236
+ debug_log = self .query_one ("#debug_log" , Log )
237
+ debug_log .write_line (message )
238
+ except Exception :
239
+ continue # Skip if still unavailable
240
+ self ._deferred_debug_log .clear ()
241
+
242
+ self .address_book = self .load_address_book ()
243
+ self .update_address_book ()
142
244
143
245
self .write_log ("Initializing Reticulum..." )
144
246
try :
@@ -161,16 +263,17 @@ async def on_mount(self):
161
263
else :
162
264
self .write_log ("Server hexhash not provided. Please wait for an announce..." )
163
265
164
- async def connect_client (self ):
165
- if not self .server_hexhash :
266
+ async def connect_client (self , destination_hash = None ):
267
+ server_hexhash = destination_hash or self .server_hexhash
268
+ if not server_hexhash :
166
269
self .write_log ("[CONNECT] Failed: No server hexhash provided." )
167
270
return
168
271
169
272
try :
170
273
try :
171
- server_addr = bytes .fromhex (self . server_hexhash )
274
+ server_addr = bytes .fromhex (server_hexhash )
172
275
except ValueError :
173
- self .write_log (f"[CONNECT] Failed: Invalid server hexhash: { self . server_hexhash } ." )
276
+ self .write_log (f"[CONNECT] Failed: Invalid server hexhash: { server_hexhash } ." )
174
277
return
175
278
176
279
if not RNS .Transport .has_path (server_addr ):
@@ -233,9 +336,8 @@ async def wait_for_link(self):
233
336
await asyncio .sleep (0.1 )
234
337
235
338
def on_link_established (self , link ):
236
- self .write_log ("Connected to the RetiBBS server." )
237
- self .write_log ("[DEBUG] Link established!" )
238
- self .write_log (f"[DEBUG] Link status: { link .status } " )
339
+ self .write_debug_log ("[DEBUG] Link established!" )
340
+ self .write_debug_log (f"[DEBUG] Link status: { link .status } " )
239
341
240
342
def on_link_closed (self , link ):
241
343
self .write_log ("Disconnected from the RetiBBS server." )
@@ -302,10 +404,12 @@ async def on_input_submitted(self, message: Input.Submitted):
302
404
message .input .value = ""
303
405
304
406
def on_announce (self , destination_hash , announced_identity , app_data ):
305
- self .write_debug_log ("[ANNOUNCE] [Callback] Received announce packet." )
407
+ # TOREMOVE: Debug log
408
+ #self.write_debug_log("[ANNOUNCE] [Callback] Received announce packet.")
306
409
307
410
dest_hash_hex = destination_hash .hex ()
308
411
display_name = RNS .prettyhexrep (destination_hash )
412
+ timestamp = time .strftime ("%Y-%m-%d %H:%M:%S" , time .localtime ())
309
413
310
414
if app_data :
311
415
try :
@@ -318,17 +422,53 @@ def on_announce(self, destination_hash, announced_identity, app_data):
318
422
self .servers [dest_hash_hex ] = {
319
423
"display_name" : display_name ,
320
424
"hash" : dest_hash_hex ,
425
+ "timestamp" : timestamp ,
321
426
}
322
427
323
428
self .call_later (self .update_server_list )
324
429
325
430
self .write_debug_log (f"[ANNOUNCE] Discovered server: { display_name } ({ dest_hash_hex } )" )
326
431
327
432
def update_server_list (self ):
328
- server_list = self .query_one ("#server_list" , DataTable )
329
- server_list .clear ()
330
- for server in self .servers .values ():
331
- server_list .add_row (server ["display_name" ], server ["hash" ])
433
+ try :
434
+ # Check if a modal is currently active
435
+ if len (self .screen_stack ) > 1 and isinstance (self .screen_stack [- 1 ], ModalScreen ):
436
+ if not self .server_list_update_pending :
437
+ self .server_list_update_pending = True # Set flag to avoid repeated deferrals
438
+ self .write_debug_log ("[INFO] Modal is active, deferring server list update." )
439
+ self .call_later (self .update_server_list ) # Defer the update
440
+ return
441
+
442
+ # Proceed with updating the server list
443
+ self .server_list_update_pending = False # Reset the flag
444
+ server_list = self .query_one ("#server_list" , DataTable )
445
+ server_list .clear ()
446
+ for server in self .servers .values ():
447
+ server_list .add_row (server ["display_name" ], server ["hash" ])
448
+ self .write_debug_log ("[DEBUG] Server list updated successfully." )
449
+ except Exception as e :
450
+ self .server_list_update_pending = False # Ensure the flag is reset on error
451
+ self .write_debug_log (f"[ERROR] Error updating server list: { e } " )
452
+
453
+ def update_address_book (self ):
454
+ try :
455
+ address_book = self .query_one ("#address_book" , DataTable )
456
+ address_book .clear ()
457
+ for server in self .address_book .values ():
458
+ address_book .add_row (server ["display_name" ], server ["hash" ])
459
+ self .write_debug_log (f"[DEBUG] Added to address book: { server ['display_name' ]} - { server ['hash' ]} " )
460
+ self .write_debug_log ("[DEBUG] Address book updated successfully." )
461
+ except Exception as e :
462
+ self .write_debug_log (f"[DEBUG] Error updating address book: { e } " )
463
+
464
+ def toggle_address_book (self , destination_hash , currently_saved ):
465
+ if currently_saved :
466
+ del self .address_book [destination_hash ]
467
+ else :
468
+ server = self .servers [destination_hash ]
469
+ self .address_book [destination_hash ] = server
470
+ self .save_address_book ()
471
+ self .update_address_book ()
332
472
333
473
def action_refresh_servers (self ):
334
474
self .write_log ("[ACTION] Refreshing server list..." )
@@ -337,6 +477,48 @@ def action_refresh_servers(self):
337
477
server_list .add_columns ("Server Name" , "Destination Hash" )
338
478
for server in self .servers .values ():
339
479
server_list .add_row (server ["display_name" ], server ["hash" ])
480
+
481
+ def on_data_table_row_selected (self , event : DataTable .RowSelected ) -> None :
482
+ try :
483
+ # Get the triggering table
484
+ triggering_table = event .control
485
+ table_id = triggering_table .id # Retrieve the table's ID
486
+ self .write_debug_log (f"[ACTION] Selected row in table: { table_id } , row key: { event .row_key } " )
487
+
488
+ if table_id == "server_list" :
489
+ # Handle server list selection
490
+ row_data = triggering_table .get_row (event .row_key )
491
+ if row_data :
492
+ server_name , destination_hash = row_data
493
+ server_info = self .servers .get (destination_hash )
494
+ if server_info :
495
+ self .push_screen (
496
+ ServerDetailScreen (
497
+ server_name = server_info ["display_name" ],
498
+ destination_hash = server_info ["hash" ],
499
+ timestamp = server_info ["timestamp" ],
500
+ on_connect = self .connect_client ,
501
+ saved_in_address_book = destination_hash in self .address_book ,
502
+ )
503
+ )
504
+ elif table_id == "address_book" :
505
+ # Handle address book selection
506
+ row_data = triggering_table .get_row (event .row_key )
507
+ if row_data :
508
+ server_name , destination_hash = row_data
509
+ server_info = self .address_book .get (destination_hash )
510
+ if server_info :
511
+ self .push_screen (
512
+ ServerDetailScreen (
513
+ server_name = server_info ["display_name" ],
514
+ destination_hash = server_info ["hash" ],
515
+ timestamp = server_info ["timestamp" ],
516
+ on_connect = self .connect_client ,
517
+ saved_in_address_book = True ,
518
+ )
519
+ )
520
+ except Exception as e :
521
+ self .write_debug_log (f"[ERROR] Exception in row selection: { e } " )
340
522
341
523
if __name__ == "__main__" :
342
524
parser = argparse .ArgumentParser (description = "RetiBBS Client" )
0 commit comments