From 3ce348dbb224cd88e1a8088c6627465ef871ad79 Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Wed, 20 May 2026 16:16:24 -0400 Subject: [PATCH] Add trusted/untrusted tabs with trust banner, peer-info ping/block/note/pi propagation-node picker and last-sync indicator, warning badge for failed outgoing messages, ASCII qrcodes QR, for own own and peer LXMF addresses, RRC MOTD pane, collapsible/clickable channel and users panes with F8 join/part collapse, Guide info update, anchor microntags # --- nomadnet/Conversation.py | 56 +- nomadnet/Directory.py | 12 +- nomadnet/NomadNetworkApp.py | 64 +- nomadnet/RRC.py | 13 +- nomadnet/ui/TextUI.py | 1 + nomadnet/ui/textui/Browser.py | 55 ++ nomadnet/ui/textui/Channels.py | 418 ++++++++++- nomadnet/ui/textui/Conversations.py | 1017 +++++++++++++++++++++++++-- nomadnet/ui/textui/Guide.py | 257 ++++++- nomadnet/ui/textui/MicronParser.py | 72 +- 10 files changed, 1826 insertions(+), 139 deletions(-) diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py index d7039dc..2adb9c3 100644 --- a/nomadnet/Conversation.py +++ b/nomadnet/Conversation.py @@ -11,6 +11,7 @@ from LXMF import display_name_from_app_data class Conversation: cached_conversations = {} unread_conversations = {} + failed_conversations = {} created_callback = None aspect_filter = "lxmf.delivery" @@ -59,7 +60,7 @@ class Conversation: source_hash = lxmessage.destination_hash else: source_hash = lxmessage.source_hash - + source_hash_path = RNS.hexrep(source_hash, delimit=False) conversation_path = app.conversationpath + "/" + source_hash_path @@ -80,17 +81,34 @@ class Conversation: conversation = Conversation.cached_conversations[RNS.hexrep(source_hash, delimit=False)] conversation.scan_storage() - if source_hash in Conversation.unread_conversations: - Conversation.unread_conversations[source_hash] += 1 - else: - Conversation.unread_conversations[source_hash] = 1 + lxm_state = getattr(lxmessage, "state", None) + is_failed = lxm_state in (LXMF.LXMessage.FAILED, LXMF.LXMessage.REJECTED) - try: - dirname = RNS.hexrep(source_hash, delimit=False) - with open(app.conversationpath + "/" + dirname + "/unread", "w") as uf: - uf.write(str(Conversation.unread_conversations[source_hash])) - except Exception as e: - pass + if not originator: + if source_hash in Conversation.unread_conversations: + Conversation.unread_conversations[source_hash] += 1 + else: + Conversation.unread_conversations[source_hash] = 1 + + try: + dirname = RNS.hexrep(source_hash, delimit=False) + with open(app.conversationpath + "/" + dirname + "/unread", "w") as uf: + uf.write(str(Conversation.unread_conversations[source_hash])) + except Exception: + pass + + elif is_failed: + if source_hash in Conversation.failed_conversations: + Conversation.failed_conversations[source_hash] += 1 + else: + Conversation.failed_conversations[source_hash] = 1 + + try: + dirname = RNS.hexrep(source_hash, delimit=False) + with open(app.conversationpath + "/" + dirname + "/failed", "w") as ff: + ff.write(str(Conversation.failed_conversations[source_hash])) + except Exception: + pass if Conversation.created_callback != None: Conversation.created_callback() @@ -120,6 +138,18 @@ class Conversation: unread = 1 Conversation.unread_conversations[source_hash] = unread + failed = 0 + if source_hash in Conversation.failed_conversations: + failed = Conversation.failed_conversations[source_hash] + elif os.path.isfile(app.conversationpath + "/" + dirname + "/failed"): + try: + with open(app.conversationpath + "/" + dirname + "/failed", "r") as ff: + content = ff.read().strip() + failed = int(content) if content else 1 + except Exception: + failed = 1 + Conversation.failed_conversations[source_hash] = failed + if display_name == None and app_data: display_name = LXMF.display_name_from_app_data(app_data) @@ -127,7 +157,7 @@ class Conversation: sort_name = "" else: sort_name = display_name - + trust_level = app.directory.trust_level(source_hash, display_name) conversation_dir = app.conversationpath + "/" + dirname @@ -136,7 +166,7 @@ class Conversation: except Exception: last_activity = 0 - entry = (source_hash_text, display_name, trust_level, sort_name, unread, last_activity) + entry = (source_hash_text, display_name, trust_level, sort_name, unread, last_activity, failed) conversations.append(entry) except Exception as e: diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index a3f86d5..dae86ec 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -90,7 +90,7 @@ class Directory: packed_list = [] for source_hash in self.directory_entries: e = self.directory_entries[source_hash] - packed_list.append((e.source_hash, e.display_name, e.trust_level, e.hosts_node, e.preferred_delivery, e.identify, e.sort_rank)) + packed_list.append((e.source_hash, e.display_name, e.trust_level, e.hosts_node, e.preferred_delivery, e.identify, e.sort_rank, getattr(e, "notes", ""))) directory = { "entry_list": packed_list, @@ -138,7 +138,12 @@ class Directory: else: sort_rank = None - entries[e[0]] = DirectoryEntry(e[0], e[1], e[2], hosts_node, preferred_delivery=preferred_delivery, identify_on_connect=identify, sort_rank=sort_rank) + if len(e) > 7: + notes = e[7] + else: + notes = None + + entries[e[0]] = DirectoryEntry(e[0], e[1], e[2], hosts_node, preferred_delivery=preferred_delivery, identify_on_connect=identify, sort_rank=sort_rank, notes=notes) self.directory_entries = entries @@ -412,7 +417,7 @@ class DirectoryEntry: DIRECT = 0x01 PROPAGATED = 0x02 - def __init__(self, source_hash, display_name=None, trust_level=UNKNOWN, hosts_node=False, preferred_delivery=None, identify_on_connect=False, sort_rank=None): + def __init__(self, source_hash, display_name=None, trust_level=UNKNOWN, hosts_node=False, preferred_delivery=None, identify_on_connect=False, sort_rank=None, notes=None): if len(source_hash) == RNS.Identity.TRUNCATED_HASHLENGTH//8: self.source_hash = source_hash self.display_name = display_name @@ -426,5 +431,6 @@ class DirectoryEntry: self.trust_level = trust_level self.hosts_node = hosts_node self.identify = identify_on_connect + self.notes = notes or "" else: raise TypeError("Attempt to add invalid source hash to directory") diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index f3afd73..a9316f9 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -543,6 +543,52 @@ class NomadNetworkApp: if self.message_router.propagation_transfer_state != LXMF.LXMRouter.PR_IDLE: self.message_router.cancel_propagation_node_requests() + def _persist_ignored_list(self): + try: + with open(self.ignoredpath, "wb") as fh: + for h in self.ignored_list: + fh.write((RNS.hexrep(h, delimit=False)+"\n").encode("utf-8")) + except Exception as e: + RNS.log("Could not persist ignored list: "+str(e), RNS.LOG_ERROR) + + def block_destination(self, dest_hash, reason=None): + if not isinstance(dest_hash, (bytes, bytearray)): + return False + dest_hash = bytes(dest_hash) + try: + identity = RNS.Identity.recall(dest_hash) + if identity is not None: + RNS.Transport.blackhole_identity(identity.hash, reason=reason) + except Exception as e: + RNS.log("Could not blackhole identity: "+str(e), RNS.LOG_ERROR) + if dest_hash not in self.ignored_list: + self.ignored_list.append(dest_hash) + self._persist_ignored_list() + try: + self.message_router.ignore_destination(dest_hash) + except Exception: + pass + return True + + def unblock_destination(self, dest_hash): + if not isinstance(dest_hash, (bytes, bytearray)): + return False + dest_hash = bytes(dest_hash) + try: + identity = RNS.Identity.recall(dest_hash) + if identity is not None: + RNS.Transport.unblackhole_identity(identity.hash) + except Exception as e: + RNS.log("Could not lift blackhole on identity: "+str(e), RNS.LOG_ERROR) + if dest_hash in self.ignored_list: + self.ignored_list.remove(dest_hash) + self._persist_ignored_list() + try: + self.message_router.unignore_destination(dest_hash) + except Exception: + pass + return True + def announce_now(self): RNS.log("Sending lxmf.delivery announce", RNS.LOG_VERBOSE) self.message_router.set_inbound_stamp_cost(self.lxmf_destination.hash, self.required_stamp_cost) @@ -704,16 +750,24 @@ class NomadNetworkApp: return False def conversation_is_unread(self, source_hash): - if bytes.fromhex(source_hash) in nomadnet.Conversation.unread_conversations: + src_bytes = bytes.fromhex(source_hash) + if src_bytes in nomadnet.Conversation.unread_conversations: return True - else: - return False + if src_bytes in nomadnet.Conversation.failed_conversations: + return True + return False def mark_conversation_read(self, source_hash): - if bytes.fromhex(source_hash) in nomadnet.Conversation.unread_conversations: - nomadnet.Conversation.unread_conversations.pop(bytes.fromhex(source_hash)) + src_bytes = bytes.fromhex(source_hash) + if src_bytes in nomadnet.Conversation.unread_conversations: + nomadnet.Conversation.unread_conversations.pop(src_bytes) if os.path.isfile(self.conversationpath + "/" + source_hash + "/unread"): os.unlink(self.conversationpath + "/" + source_hash + "/unread") + if src_bytes in nomadnet.Conversation.failed_conversations: + nomadnet.Conversation.failed_conversations.pop(src_bytes) + if os.path.isfile(self.conversationpath + "/" + source_hash + "/failed"): + try: os.unlink(self.conversationpath + "/" + source_hash + "/failed") + except Exception: pass def notify_message_recieved(self): if self.uimode == nomadnet.ui.UI_TEXT: diff --git a/nomadnet/RRC.py b/nomadnet/RRC.py index 1335fe1..4aafc85 100644 --- a/nomadnet/RRC.py +++ b/nomadnet/RRC.py @@ -216,6 +216,7 @@ class RRCHub: self.welcomed = False self.hub_name = None self.hub_version = None + self.motd = None self.max_nick_bytes = DEFAULT_MAX_NICK_BYTES self.max_room_name_bytes = DEFAULT_MAX_ROOM_BYTES @@ -453,6 +454,7 @@ class RRCHub: with self._lock: self.link = None self.welcomed = False + self.motd = None self.members.clear() self._resource_expectations.clear() self._pending_joins.clear() @@ -1043,9 +1045,14 @@ class RRCHub: self.manager._notify_change(self) if silent_who: return + room_n = room.strip().lower() if isinstance(room, str) else None + if room_n is None and isinstance(body, str) and body.strip(): + with self._lock: + self.motd = body + self.manager._notify_change(self) msg = RRCMessage( "notice", - room.strip().lower() if isinstance(room, str) else None, + room_n, bytes(src) if isinstance(src, (bytes, bytearray)) else None, None, body, @@ -1172,6 +1179,10 @@ class RRCHub: text = data.decode(encoding, errors="replace") except Exception: return + if kind == RES_KIND_MOTD: + with self._lock: + self.motd = text + self.manager._notify_change(self) msg = RRCMessage("notice", room, None, None, text, _now_ms()) self._record_notice(msg) except Exception as e: diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index f05ae81..89d34c9 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -164,6 +164,7 @@ GLYPHS = { ("file", "[F]", "\u25a4", "\uf15b"), ("image", "[I]", "\u25a3", "\uf1c5"), ("audio", "[~]", "\u266b", "\uf1c7"), + ("pin", "*", "\u2605", "\uf08d"), } class TextUI: diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 1913fb9..20c6e20 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -253,6 +253,11 @@ class Browser: recurse_down(self.attr_maps) RNS.log("Including request data: "+str(request_data), RNS.LOG_DEBUG) + # In-document anchor link (#name or empty #) + if link_target.startswith("#"): + self._jump_to_anchor(link_target[1:]) + return + # rrc://[:]/ URL form if link_target.startswith("rrc://"): self.handle_rrc_link(link_target[6:]) @@ -300,6 +305,56 @@ class Browser: self.browser_footer = urwid.Text("Could not open link: "+"No known handler for destination type "+str(destination_type)) self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + def _jump_to_anchor(self, name): + anchors = getattr(self.attr_maps, "anchors", None) or {} + header_rows = getattr(self.attr_maps, "header_rows", None) or [] + + cols = self._content_cols() + + target_idx = None + if name: + target_idx = anchors.get(name) + if target_idx is None: + self.browser_footer = urwid.Text("Unknown anchor: #"+name) + self.frame.contents["footer"] = (self.browser_footer, self.frame.options()) + return + else: + current = 0 + try: + current = self.browser_body.original_widget.original_widget.get_scrollpos() + except Exception: + current = 0 + for hr in header_rows: + if self._rows_above(hr, cols) > current: + target_idx = hr + break + if target_idx is None: + return + + try: + scrollable = self.browser_body.original_widget.original_widget + scrollable.set_scrollpos(self._rows_above(int(target_idx), cols)) + except Exception as e: + RNS.log("Anchor jump failed: "+str(e), RNS.LOG_ERROR) + + def _content_cols(self): + try: + cols = self.app.ui.loop.screen.get_cols_rows()[0] + except Exception: + cols = 100 + return max(40, cols - 3) + + def _rows_above(self, index, cols): + if index <= 0 or not self.attr_maps: + return 0 + total = 0 + for i in range(min(index, len(self.attr_maps))): + try: + total += self.attr_maps[i].rows((cols,)) + except Exception: + total += 1 + return total + def handle_lxmf_link(self, link_target): try: def san(name): diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index ea119d8..afa568b 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -13,7 +13,8 @@ from nomadnet.ui.textui.MicronParser import LinkableText, LinkSpec from RNS.Utilities.rngit.util import MarkdownToMicron from RNS.Utilities.rngit.highlight import SyntaxHighlighter from .MicronParser import markup_to_attrmaps -from nomadnet.util import strip_modifiers +from nomadnet.util import strip_modifiers, sanitize_name +from nomadnet.vendor.Scrollable import Scrollable, ScrollBar theme_dark = { "ts": "888", @@ -171,13 +172,13 @@ def _format_ts(ts_ms): class ChannelsListShortcuts(): def __init__(self, app): self.app = app - self.widget = urwid.AttrMap(urwid.Text("[C-n] New Hub [C-a] Add Room [C-r] Connect [C-w] Disconnect [C-t] Auto-reconnect [C-e] Edit Hub [C-x] Remove"), "shortcutbar") + self.widget = urwid.AttrMap(urwid.Text("[C-n] New Hub [C-a] Add Room [C-r] Connect [C-w] Disconnect [C-t] Auto-reconnect [C-e] Edit Hub [C-x] Remove [C-y] Toggle Channels"), "shortcutbar") class ChannelsRoomShortcuts(): def __init__(self, app): self.app = app - self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-l] Leave Room [C-k] Clear Editor [C-u] Toggle Users [Tab] Switch Focus"), "shortcutbar") + self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-l] Leave [C-k] Clear [C-u] Users [C-y] Channels [F8] Collapse Joins [Tab] Focus"), "shortcutbar") class ChannelsDialogLineBox(urwid.LineBox): @@ -205,7 +206,101 @@ class ChannelListEntry(urwid.Text): return True +class ChannelsExpandGutter(urwid.WidgetWrap): + def __init__(self, app, delegate): + self.app = app + self.delegate = delegate + glyph = app.ui.glyphs.get("arrow_r", ">") + if len(glyph) > 1: + glyph = ">" + self._inner = urwid.SolidFill(glyph) + super().__init__(urwid.AttrMap(self._inner, "shortcutbar", "list_focus")) + + def mouse_event(self, size, event, button, col, row, focus): + if button == 1 and urwid.util.is_mouse_press(event): + try: + self.delegate.toggle_channel_list() + return True + except Exception: + pass + return False + + def selectable(self): + return False + + +class UsersExpandGutter(urwid.WidgetWrap): + def __init__(self, app, delegate): + self.app = app + self.delegate = delegate + glyph = app.ui.glyphs.get("arrow_l", "<") + if len(glyph) > 1: + glyph = "<" + self._inner = urwid.SolidFill(glyph) + super().__init__(urwid.AttrMap(self._inner, "shortcutbar", "list_focus")) + + def mouse_event(self, size, event, button, col, row, focus): + if button == 1 and urwid.util.is_mouse_press(event): + try: + self.delegate.toggle_users() + return True + except Exception: + pass + return False + + def selectable(self): + return False + + +class UsersBox(urwid.LineBox): + def mouse_event(self, size, event, button, col, row, focus): + if button == 1 and urwid.util.is_mouse_press(event) and row == 0: + try: + self.delegate.toggle_users() + return True + except Exception: + pass + return super().mouse_event(size, event, button, col, row, focus) + + def keypress(self, size, key): + if key == "tab": + rw = getattr(self, "delegate", None) + if rw is not None: + try: + rw.columns.focus_position = 0 + rw.frame.focus_position = "footer" + return None + except Exception: + pass + if key == "ctrl u": + rw = getattr(self, "delegate", None) + if rw is not None: + try: + rw.toggle_users() + return None + except Exception: + pass + if key == "ctrl y": + rw = getattr(self, "delegate", None) + if rw is not None: + try: + rw.display.toggle_channel_list() + return None + except Exception: + pass + return super().keypress(size, key) + + class ChannelsListArea(urwid.LineBox): + def mouse_event(self, size, event, button, col, row, focus): + if button == 1 and urwid.util.is_mouse_press(event) and row == 0: + try: + self.delegate.toggle_channel_list() + return True + except Exception: + pass + return super().mouse_event(size, event, button, col, row, focus) + def keypress(self, size, key): if key == "ctrl n": self.delegate.new_hub_dialog() @@ -221,6 +316,12 @@ class ChannelsListArea(urwid.LineBox): self.delegate.edit_hub_dialog() elif key == "ctrl x": self.delegate.remove_selected_dialog() + elif key == "ctrl y": + self.delegate.toggle_channel_list() + return None + elif key == "f8": + self.delegate.toggle_join_part_collapse() + return None elif key == "tab": self.delegate.app.ui.main_display.frame.focus_position = "header" elif key == "up" and (self.delegate.ilb.first_item_is_selected() or self.delegate.ilb.body_is_empty()): @@ -252,6 +353,12 @@ class HubInfoArea(urwid.LineBox): if key == "ctrl x": self.delegate.remove_selected_dialog() return None + if key == "ctrl y": + self.delegate.toggle_channel_list() + return None + if key == "f8": + self.delegate.toggle_join_part_collapse() + return None return super(HubInfoArea, self).keypress(size, key) @@ -274,6 +381,10 @@ class RoomMessageEdit(urwid.Edit): self.delegate.leave_room() elif key == "ctrl u": self.delegate.toggle_users() + elif key == "ctrl y": + self.delegate.display.toggle_channel_list() + elif key == "f8": + self.delegate.display.toggle_join_part_collapse() elif key == "up": y = self.get_cursor_coords(size)[1] if y == 0: @@ -358,11 +469,31 @@ class RoomFrame(urwid.Frame): if key == "ctrl u": self.delegate.toggle_users() return None + if key == "ctrl y": + self.delegate.display.toggle_channel_list() + return None + if key == "f8": + self.delegate.display.toggle_join_part_collapse() + return None if key == "tab": + rw = self.delegate + users_focusable = ( + rw is not None + and getattr(rw, "users_visible", False) + and len(rw.columns.contents) > 1 + and rw.columns.contents[1][0] is rw.users_box + ) if self.focus_position == "body": self.focus_position = "footer" + elif users_focusable: + try: + rw.columns.focus_position = 1 + return None + except Exception: + self.focus_position = "body" else: self.focus_position = "body" + return None elif self.focus_position == "body": if key == "down" and getattr(self.delegate, "messagelist", None) is not None and self.delegate.messagelist.bottom_is_visible: self.focus_position = "footer" @@ -406,8 +537,11 @@ class RoomWidget(urwid.WidgetWrap): self.frame.delegate = self self.chat_box = urwid.LineBox(self.frame) - self.users_pile = urwid.Pile([urwid.Text("")]) - self.users_box = urwid.LineBox(urwid.Filler(self.users_pile, "top"), title="Users") + self.users_walker = urwid.SimpleFocusListWalker([urwid.Text("")]) + self.users_listbox = urwid.ListBox(self.users_walker) + self.users_box = UsersBox(self.users_listbox, title="Users") + self.users_box.delegate = self + self.users_gutter = UsersExpandGutter(self.app, self) self._refresh_users_pane() self.users_visible = self.display.users_visible @@ -428,34 +562,73 @@ class RoomWidget(urwid.WidgetWrap): ] else: self.columns.contents = [ - (self.chat_box, self.columns.options(urwid.WEIGHT, 1)), + (self.chat_box, self.columns.options(urwid.WEIGHT, 1)), + (self.users_gutter, self.columns.options(urwid.GIVEN, 1)), ] self.columns.focus_position = 0 def _refresh_users_pane(self): g = self.app.ui.glyphs + walker = self.users_walker if self.hub is None or self.room is None: - self.users_pile.contents = [(urwid.Text(""), self.users_pile.options())] + walker[:] = [urwid.Text("")] return members = self.hub.get_members(self.room) own_hash = self.app.identity.hash if self.app.identity is not None else None - names = [] - for m in members: - if own_hash is not None and m == own_hash: - names.append((self.hub.display_name_for(m), True)) - else: - names.append((self.hub.display_name_for(m), False)) - names.sort(key=lambda x: x[0].lower()) + def _safe_name(raw): + if not raw: return "" + try: + if self.app.config["textui"]["sanitize_names"]: + return sanitize_name(str(raw)) or "" + return strip_modifiers(str(raw)) or "" + except Exception: + return str(raw or "") - rows = [urwid.Text(" "+str(len(names))+" user"+("s" if len(names) != 1 else ""))] - for name, is_self in names: + entries = [] + for m in members: + entries.append((_safe_name(self.hub.display_name_for(m)), m, own_hash is not None and m == own_hash)) + entries.sort(key=lambda x: x[0].lower()) + + prev_focus_key = None + try: + prev_idx = walker.focus + if prev_idx is not None and 0 <= prev_idx < len(walker): + prev_focus_key = getattr(walker[prev_idx], "user_hash", None) + except Exception: + prev_focus_key = None + + rows = [urwid.Text(" "+str(len(entries))+" user"+("s" if len(entries) != 1 else ""))] + for name, peer_hash, is_self in entries: if is_self: - rows.append(urwid.AttrMap(urwid.Text(" "+g["arrow_r"]+" "+name), "list_trusted")) + label = " "+g["arrow_r"]+" "+name + style = "list_trusted" else: - rows.append(urwid.AttrMap(urwid.Text(" "+g["peer"]+" "+name), "connected_status")) - if not names: + label = " "+g["peer"]+" "+name + style = "connected_status" + entry = ChannelListEntry(label) + urwid.connect_signal(entry, "click", self.display.show_user_info, (self.hub, peer_hash, name)) + row = urwid.AttrMap(entry, style, "list_focus") + row.user_hash = peer_hash + rows.append(row) + if not entries: rows.append(urwid.Text(" (no members)")) - self.users_pile.contents = [(w, self.users_pile.options()) for w in rows] + + walker[:] = rows + + new_focus = None + if prev_focus_key is not None: + for idx, w in enumerate(walker): + if getattr(w, "user_hash", None) == prev_focus_key: + new_focus = idx + break + if new_focus is None: + for idx, w in enumerate(walker): + if hasattr(w, "user_hash"): + new_focus = idx + break + if new_focus is not None: + try: walker.set_focus(new_focus) + except Exception: pass def _update_peer_info(self): if self.hub is None or self.room is None: @@ -481,8 +654,22 @@ class RoomWidget(urwid.WidgetWrap): def update_messages(self, replace=False): msgs = self.hub.get_messages(self.room) if (self.hub is not None and self.room is not None) else [] widgets = [] + collapse = getattr(self.display, "collapse_join_part", False) + run = [] + + def flush_run(): + if not run: + return + widgets.append(_collapsed_joinpart_widget(self.app, len(run))) + run.clear() + for m in msgs: + if collapse and _is_joinpart_system(m): + run.append(m) + continue + flush_run() widgets.append(_message_widget(self.app, self.hub, m, link_delegate=self.link_delegate)) + flush_run() if not widgets: widgets = [urwid.Text([("irc_system", " "+self.app.ui.glyphs["info"]+" No messages yet")])] @@ -498,7 +685,7 @@ class RoomWidget(urwid.WidgetWrap): pass if replace and hasattr(self, "frame"): self.frame.contents["body"] = (self.messagelist, None) - if hasattr(self, "users_pile"): + if hasattr(self, "users_walker"): self._refresh_users_pane() def append_message(self, msg): @@ -549,7 +736,7 @@ class RoomWidget(urwid.WidgetWrap): except Exception as e: RNS.log("Incremental append failed, falling back: "+str(e), RNS.LOG_DEBUG) self.update_messages(replace=True) - if hasattr(self, "users_pile"): + if hasattr(self, "users_walker"): self._refresh_users_pane() def _on_editor_change(self, editor, old_text): @@ -942,6 +1129,32 @@ def strip_non_formatting_tags(text): return text mdc = MarkdownToMicron(max_width=80, syntax_highlighter=SyntaxHighlighter(), url_scope=None) + +_MOTD_ROOM_RE = re.compile(r"(?" + return "(select propagation node)" + + def _show_collapsed(self): + btn = urwid.Button("▾ "+self._label_for(self._current)) + urwid.connect_signal(btn, "click", self._on_expand_click) + self._pile.contents = [ + (urwid.AttrMap(btn, None, focus_map="list_focus"), self._pile.options()), + ] + + def _on_expand_click(self, _btn): + self._show_expanded() + + def _on_back_click(self, _btn): + self._show_collapsed() + + def _make_row_click(self, picked_hash): + def _click(_btn): + try: + self._current = picked_hash + self._on_change(picked_hash) + except Exception as e: + RNS.log("Propagation node change handler failed: "+str(e), RNS.LOG_ERROR) + self._show_collapsed() + return _click + + def _show_expanded(self): + rows = [] + for ph, label in self._options: + marker = "● " if ph == self._current else "○ " + row = urwid.Button(marker+label) + urwid.connect_signal(row, "click", self._make_row_click(ph)) + rows.append(urwid.AttrMap(row, None, focus_map="list_focus")) + + if not rows: + rows.append(urwid.Text(" (no propagation nodes seen yet)")) + + list_height = min(10, max(3, len(rows))) + listbox = urwid.ListBox(urwid.SimpleFocusListWalker(rows)) + boxed = urwid.BoxAdapter(listbox, list_height) + + back = urwid.Button("◀ Back") + urwid.connect_signal(back, "click", self._on_back_click) + + self._pile.contents = [ + (urwid.AttrMap(back, None, focus_map="list_focus"), self._pile.options()), + (boxed, self._pile.options()), + ] + + class DialogLineBox(urwid.LineBox): def keypress(self, size, key): if key == "esc": @@ -106,17 +186,23 @@ class ConversationsDisplay(): SORT_RECENT = 0 SORT_NAME = 1 + LIST_FILTER_TRUSTED = "trusted" + LIST_FILTER_UNTRUSTED = "untrusted" + def __init__(self, app): self.app = app self.dialog_open = False self.sync_dialog = None self.currently_displayed_conversation = None self.list_sort_mode = ConversationsDisplay.SORT_RECENT + self.list_filter = ConversationsDisplay.LIST_FILTER_TRUSTED + self.show_blocked = False def disp_list_shortcuts(sender, arg1, arg2): self.shortcuts_display = self.list_shortcuts self.app.ui.main_display.update_active_shortcuts() + self._build_persistent_listbox() self.update_listbox() self.columns_widget = urwid.Columns( @@ -133,7 +219,7 @@ class ConversationsDisplay(): self.editor_shortcuts = ConversationDisplayShortcuts(self.app) self.shortcuts_display = self.list_shortcuts - self.widget = self.columns_widget + self.widget = urwid.WidgetPlaceholder(self.columns_widget) self._pending_actions = collections.deque() self._wake_fd = None @@ -144,6 +230,11 @@ class ConversationsDisplay(): nomadnet.Conversation.created_callback = lambda: self._wake(self.update_conversation_list) + try: + self.app.ui.loop.set_alarm_in(30.0, self._refresh_sync_status) + except Exception: + pass + def _process_pending(self, data): while True: try: @@ -180,26 +271,279 @@ class ConversationsDisplay(): self.list_sort_mode = ConversationsDisplay.SORT_RECENT self.update_conversation_list() - def update_listbox(self): - conversations = self.app.conversations() - if self.list_sort_mode == ConversationsDisplay.SORT_NAME: - conversations.sort(key=lambda e: (e[3].lower(), e[0])) + def _conversation_filter_predicate(self, conversation): + try: + trust_level = conversation[2] + except Exception: + return False + if self.list_filter == ConversationsDisplay.LIST_FILTER_UNTRUSTED: + return trust_level in (DirectoryEntry.UNTRUSTED, DirectoryEntry.WARNING, DirectoryEntry.UNKNOWN) + return trust_level == DirectoryEntry.TRUSTED - conversation_list_widgets = [] - for conversation in conversations: - conversation_list_widgets.append(self.conversation_list_widget(conversation)) + def _set_filter(self, key): + if self.list_filter == key: + return + self.list_filter = key + try: + self.update_conversation_list() + except Exception as e: + RNS.log("Failed to apply conversation filter: "+str(e), RNS.LOG_ERROR) + + def _on_show_blocked_change(self, _cb, new_state): + self.show_blocked = new_state + try: + self.update_conversation_list() + except Exception as e: + RNS.log("Failed to toggle show-blocked: "+str(e), RNS.LOG_ERROR) + + def _apply_pile_layout(self): + pack_opts = self.pile.options('pack') + weight_opts = self.pile.options('weight', 1) + items = [(self.tab_bar, pack_opts)] + if self.list_filter == ConversationsDisplay.LIST_FILTER_UNTRUSTED: + items.append((self.show_blocked_checkbox, pack_opts)) + items.append((self.ilb, weight_opts)) + items.append((self.sync_status_text, pack_opts)) + try: + prev_focus = self.pile.focus_position + except Exception: + prev_focus = None + self.pile.contents = items + try: + if prev_focus is not None and prev_focus < len(items): + self.pile.focus_position = prev_focus + except Exception: + pass + + def _blocked_row_widget(self, dest_hash): + g = self.app.ui.glyphs + entry = self.app.directory.find(dest_hash) + display_name = None + if entry is not None and getattr(entry, "display_name", None): + display_name = strip_modifiers(entry.display_name) + if not display_name: + display_name = RNS.prettyhexrep(dest_hash) + label = " "+g["cross"]+" [blocked] "+display_name+" <"+RNS.hexrep(dest_hash, delimit=False)+">" + widget = ListEntry(label) + urwid.connect_signal(widget, "click", self._unblock_dialog, dest_hash) + attr = urwid.AttrMap(widget, "list_untrusted", "list_focus_untrusted") + attr.blocked_dest_hash = dest_hash + return attr + + def _unblock_dialog(self, _sender, dest_hash): + self.dialog_open = True + + def dismiss(_b): + self.dialog_open = False + self.update_conversation_list() + + def confirmed(_b): + self.dialog_open = False + try: + self.app.unblock_destination(dest_hash) + except Exception as e: + RNS.log("Unblock failed: "+str(e), RNS.LOG_ERROR) + self.update_conversation_list() + + try: + who = self.app.directory.simplest_display_str(dest_hash) + except Exception: + who = RNS.hexrep(dest_hash, delimit=False) + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text(""), + urwid.Text("Unblock "+str(who)+"?\n\nThis lifts the RNS blackhole on the peer's identity\nand removes them from your ignored list.\n", align=urwid.CENTER), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes, unblock", on_press=confirmed)), + (urwid.WEIGHT, 0.10, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=dismiss)), + ]), + ]), title="Confirm unblock" + ) + dialog.delegate = self + + bottom = self.listbox + overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2) + try: + self.columns_widget.contents[0] = ( + overlay, + self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width), + ) + self.columns_widget.focus_position = 0 + except Exception: + pass + + def _build_persistent_listbox(self): + self.tab_trusted = TabButton("Trusted (0)", on_press=lambda _b: self._set_filter(ConversationsDisplay.LIST_FILTER_TRUSTED)) + self.tab_untrusted = TabButton("Untrusted (0)", on_press=lambda _b: self._set_filter(ConversationsDisplay.LIST_FILTER_UNTRUSTED)) + + self.tab_bar = urwid.Columns([ + ('weight', 1, self.tab_trusted), + ('weight', 1, self.tab_untrusted), + ], dividechars=1) + + self.show_blocked_checkbox = urwid.CheckBox("Show blocked (0)", state=self.show_blocked) + urwid.connect_signal(self.show_blocked_checkbox, "change", self._on_show_blocked_change) - self.list_widgets = conversation_list_widgets self.ilb = IndicativeListBox( - self.list_widgets, + [urwid.Text("")], on_selection_change=self.conversation_list_selection, initialization_is_selection_change=False, - highlight_offFocus="list_off_focus" + highlight_offFocus="list_off_focus", ) - self.listbox = ConversationsArea(urwid.Filler(self.ilb, height=urwid.RELATIVE_100), title="Conversations") + self.sync_status_text = urwid.AttrMap(urwid.Text(self._sync_status_line(), align=urwid.LEFT), "shortcutbar") + + self.pile = urwid.Pile([ + ('pack', self.tab_bar), + ('weight', 1, self.ilb), + ('pack', self.sync_status_text), + ]) + try: self.pile.focus_position = 1 + except Exception: pass + + self.listbox = ConversationsArea(self.pile, title="Conversations") self.listbox.delegate = self + def update_listbox(self): + if not hasattr(self, "pile"): + self._build_persistent_listbox() + + try: + conversations = self.app.conversations() + except Exception as e: + RNS.log("Failed to enumerate conversations: "+str(e), RNS.LOG_ERROR) + conversations = [] + + try: + if self.list_sort_mode == ConversationsDisplay.SORT_NAME: + conversations.sort(key=lambda e: (e[3].lower(), e[0])) + except Exception: + pass + + def _is_pinned(c): + try: + entry = self.app.directory.find(bytes.fromhex(c[0])) + return entry is not None and entry.sort_rank is not None + except Exception: + return False + try: + conversations = sorted(conversations, key=lambda c: 0 if _is_pinned(c) else 1) + except Exception: + pass + + glyphs = self.app.ui.glyphs + def _alerts(c): + return bool(c[4]) or (len(c) > 6 and bool(c[6])) + trusted_count = sum(1 for c in conversations if c[2] == DirectoryEntry.TRUSTED) + untrusted_count = sum(1 for c in conversations if c[2] in (DirectoryEntry.UNTRUSTED, DirectoryEntry.WARNING, DirectoryEntry.UNKNOWN)) + trusted_unread = sum(1 for c in conversations if c[2] == DirectoryEntry.TRUSTED and _alerts(c)) + untrusted_unread = sum(1 for c in conversations if c[2] in (DirectoryEntry.UNTRUSTED, DirectoryEntry.WARNING, DirectoryEntry.UNKNOWN) and _alerts(c)) + + def _label(name, total, unread): + if unread: + return f"{name} ({total}) {glyphs['unread']} {unread}" + return f"{name} ({total})" + + self.tab_trusted.set_label(_label("Trusted", trusted_count, trusted_unread)) + self.tab_untrusted.set_label(_label("Untrusted", untrusted_count, untrusted_unread)) + + filtered = [c for c in conversations if self._conversation_filter_predicate(c)] + + conversation_list_widgets = [] + for conversation in filtered: + try: + conversation_list_widgets.append(self.conversation_list_widget(conversation)) + except Exception as e: + try: hh = conversation[0] + except Exception: hh = "?" + RNS.log("Skipping conversation row for "+str(hh)+": "+str(e), RNS.LOG_ERROR) + + blocked_count = len(self.app.ignored_list) if hasattr(self.app, "ignored_list") else 0 + try: + self.show_blocked_checkbox.set_label(f"Show blocked ({blocked_count})") + except Exception: + pass + + if self.list_filter == ConversationsDisplay.LIST_FILTER_UNTRUSTED and self.show_blocked: + for blocked_hash in list(self.app.ignored_list): + try: + conversation_list_widgets.append(self._blocked_row_widget(blocked_hash)) + except Exception as e: + RNS.log("Skipping blocked row: "+str(e), RNS.LOG_ERROR) + + empty_placeholder = False + if not conversation_list_widgets: + empty_label = { + ConversationsDisplay.LIST_FILTER_TRUSTED: "No trusted conversations", + ConversationsDisplay.LIST_FILTER_UNTRUSTED: "No untrusted conversations", + }.get(self.list_filter, "No conversations") + conversation_list_widgets = [urwid.Text(empty_label, align='center')] + empty_placeholder = True + + self.list_widgets = conversation_list_widgets + + try: + self.ilb.set_body(conversation_list_widgets) + except Exception as e: + RNS.log("Failed to populate conversation list: "+str(e), RNS.LOG_ERROR) + + self._apply_pile_layout() + + if empty_placeholder: + try: self.pile.focus_position = 0 + except Exception: pass + + try: + self.sync_status_text.original_widget.set_text(self._sync_status_line()) + except Exception: + pass + + def _sync_status_line(self): + try: + last = self.app.peer_settings.get("last_lxmf_sync") + except Exception: + last = None + if not last: + when = "never" + else: + try: + when = relative_time(float(last)) + except Exception: + when = "unknown" + + node_label = None + try: + pn_hash = self.app.get_default_propagation_node() + if pn_hash is not None: + pn_ident = RNS.Identity.recall(pn_hash) + if pn_ident is not None: + node_dest = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", pn_ident) + entry = self.app.directory.find(node_dest) + if entry is not None and getattr(entry, "display_name", None): + node_label = strip_modifiers(str(entry.display_name)) or None + if node_label is None: + node_label = "<"+RNS.hexrep(pn_hash, delimit=False)[:8]+"…>" + except Exception: + node_label = None + + line = " Last sync: "+when + if node_label: + line += " ("+node_label+")" + return line + + def _refresh_sync_status(self, _loop=None, _data=None): + try: + if hasattr(self, "sync_status_text") and self.sync_status_text is not None: + self.sync_status_text.original_widget.set_text(self._sync_status_line()) + except Exception: + pass + try: + self.app.ui.loop.set_alarm_in(30.0, self._refresh_sync_status) + except Exception: + pass + def delete_selected_conversation(self): self.dialog_open = True item = self.ilb.get_selected_item() @@ -248,6 +592,218 @@ class ConversationsDisplay(): options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) self.columns_widget.contents[0] = (overlay, options) + def _refresh_open_conversation_widget(self, source_hash_text): + widget = ConversationsDisplay.cached_conversation_widgets.get(source_hash_text) + if widget is None: + return + try: + widget._trust_banner_dismissed = False + except Exception: + pass + try: + widget._refresh_trust_banner() + except Exception: + pass + try: + widget._update_peer_info() + except Exception: + pass + try: + widget.update_message_widgets(replace=True) + except Exception: + pass + + def show_my_qr(self): + try: + addr = RNS.hexrep(self.app.lxmf_destination.hash, delimit=False) + except Exception: + return + try: + display = self.app.peer_settings.get("display_name") or "My LXMF" + except Exception: + display = "My LXMF" + self.show_qr_dialog(addr, title=display) + + def show_qr_dialog(self, data, title=None): + qr_text = None + try: + import qrcode + try: + qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=1, border=1) + qr.add_data(data) + qr.make() + import io + buf = io.StringIO() + qr.print_ascii(out=buf, invert=False) + qr_text = buf.getvalue().rstrip("\n") + except Exception as e: + RNS.log("QR generation failed: "+str(e), RNS.LOG_ERROR) + qr_text = None + except Exception: + qr_text = None + + def dismiss(_b): + self._restore_listbox_pane() + + rows = [urwid.Text("")] + if qr_text is not None: + rows.append(urwid.Text(qr_text, align=urwid.CENTER)) + rows.append(urwid.Text("")) + else: + rows.append(urwid.Text("LXMF destination address:", align=urwid.CENTER)) + rows.append(urwid.Text("")) + rows += [ + urwid.Text("< "+data+" >", align=urwid.CENTER), + urwid.Text(""), + urwid.Columns([ + (urwid.WEIGHT, 1, urwid.Text("")), + (12, urwid.Button("Close", on_press=dismiss)), + (urwid.WEIGHT, 1, urwid.Text("")), + ]), + urwid.Text(""), + ] + dialog_title = "LXMF Address" if qr_text is None else "QR Code" + dialog = DialogLineBox(urwid.Pile(rows), title=dialog_title) + dialog.delegate = self + self._overlay_dialog(dialog) + + def _overlay_dialog(self, dialog): + overlay = urwid.Overlay( + dialog, self.columns_widget, + align=urwid.CENTER, width=(urwid.RELATIVE, 70), + valign=urwid.MIDDLE, height=urwid.PACK, + min_width=44, + ) + try: + self.widget.original_widget = overlay + self.dialog_open = True + except Exception: + pass + + def _restore_listbox_pane(self): + try: + self.widget.original_widget = self.columns_widget + self.dialog_open = False + self.update_conversation_list() + except Exception: + pass + + def _ping_peer_from_dialog(self, source_hash_text, status_widget, ping_button): + try: + dest = bytes.fromhex(source_hash_text) + except Exception: + status_widget.set_text(("error_text", "Invalid address")) + return + + identity = RNS.Identity.recall(dest) + if identity is None: + status_widget.set_text(("error_text", "Identity unknown; query first")) + return + + if not RNS.Transport.has_path(dest): + status_widget.set_text("No path; requesting…") + try: RNS.Transport.request_path(dest) + except Exception: pass + + status_widget.set_text("Pinging…") + ping_button.set_label("Pinging…") + started_at = time.time() + + def schedule_ui(fn): + try: + self.app.ui.loop.set_alarm_in(0, lambda *_: fn()) + except Exception: + try: fn() + except Exception: pass + + def on_established(link): + elapsed_ms = int((time.time() - started_at) * 1000) + try: + hops = RNS.Transport.hops_to(dest) + if hops is None or hops >= RNS.Transport.PATHFINDER_M: + hops_str = "" + else: + hops_str = f" ({hops} hop{'s' if hops != 1 else ''})" + except Exception: + hops_str = "" + def update(): + status_widget.set_text(f"Pong in {elapsed_ms} ms{hops_str}") + ping_button.set_label("Ping") + schedule_ui(update) + try: link.teardown() + except Exception: pass + + def on_closed(link): + try: + if getattr(link, "status", None) == RNS.Link.ACTIVE: + return + except Exception: + pass + def update(): + if status_widget.text.strip() in ("Pinging…", ""): + status_widget.set_text(("error_text", "Ping failed (no link)")) + ping_button.set_label("Ping") + schedule_ui(update) + + try: + destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery") + RNS.Link(destination, established_callback=on_established, closed_callback=on_closed) + except Exception as e: + status_widget.set_text(("error_text", f"Ping init failed: {e}")) + ping_button.set_label("Ping") + + def _block_peer_from_dialog(self, source_hash_text): + try: + dest = bytes.fromhex(source_hash_text) + except Exception: + return + + def cancel_block(_b): + self.update_conversation_list() + self.dialog_open = False + + def confirm_block(_b): + try: + self.app.block_destination(dest, reason="user-blocked from peer info dialog") + except Exception as e: + RNS.log("Block failed: "+str(e), RNS.LOG_ERROR) + try: + self.delete_conversation(source_hash_text) + nomadnet.Conversation.delete_conversation(source_hash_text, self.app) + except Exception: + pass + self.update_conversation_list() + self.dialog_open = False + + try: + who = self.app.directory.simplest_display_str(dest) + except Exception: + who = source_hash_text + + confirm_dialog = DialogLineBox( + urwid.Pile([ + urwid.Text(""), + urwid.Text("Block "+str(who)+"?\n\nThis blackholes the peer's identity in Reticulum,\nadds them to your ignored list, and deletes any\nconversation with them.\n", align=urwid.CENTER), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes, block", on_press=confirm_block)), + (urwid.WEIGHT, 0.10, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=cancel_block)), + ]), + ]), title="Confirm block" + ) + confirm_dialog.delegate = self + bottom = self.listbox + overlay = urwid.Overlay(confirm_dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2) + try: + self.columns_widget.contents[0] = ( + overlay, + self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width), + ) + self.columns_widget.focus_position = 0 + self.dialog_open = True + except Exception: + pass + def edit_selected_in_directory(self): g = self.app.ui.glyphs self.dialog_open = True @@ -262,6 +818,7 @@ class ConversationsDisplay(): e_id = urwid.Edit(caption="Addr : ",edit_text=source_hash_text) t_id = urwid.Text("Addr : "+source_hash_text) e_name = urwid.Edit(caption="Name : ",edit_text=display_name) + e_copy = urwid.Edit(caption="Copy : ", edit_text=source_hash_text) selected_id_widget = t_id @@ -272,8 +829,12 @@ class ConversationsDisplay(): direct_selected = True propagated_selected = False + pinned_initial = False + notes_initial = "" + try: - if self.app.directory.find(bytes.fromhex(source_hash_text)): + existing_entry = self.app.directory.find(bytes.fromhex(source_hash_text)) + if existing_entry: trust_level = self.app.directory.trust_level(bytes.fromhex(source_hash_text)) if trust_level == DirectoryEntry.UNTRUSTED: untrusted_selected = True @@ -291,10 +852,16 @@ class ConversationsDisplay(): if self.app.directory.preferred_delivery(bytes.fromhex(source_hash_text)) == DirectoryEntry.PROPAGATED: direct_selected = False propagated_selected = True - + + pinned_initial = existing_entry.sort_rank is not None + notes_initial = getattr(existing_entry, "notes", "") or "" + except Exception as e: pass + e_notes = urwid.Edit(caption="Notes: ", edit_text=notes_initial, multiline=True) + cb_pin = urwid.CheckBox("Pin to top", state=pinned_initial) + trust_button_group = [] r_untrusted = urwid.RadioButton(trust_button_group, "Untrusted", state=untrusted_selected) r_unknown = urwid.RadioButton(trust_button_group, "Unknown", state=unknown_selected) @@ -322,8 +889,11 @@ class ConversationsDisplay(): if r_propagated.state == True: delivery = DirectoryEntry.PROPAGATED - entry = DirectoryEntry(source_hash, display_name, trust_level, preferred_delivery=delivery) + sort_rank = 0 if cb_pin.state else None + notes_value = e_notes.get_edit_text() + entry = DirectoryEntry(source_hash, display_name, trust_level, preferred_delivery=delivery, sort_rank=sort_rank, notes=notes_value) self.app.directory.remember(entry) + self._refresh_open_conversation_widget(source_hash_text) self.update_conversation_list() self.dialog_open = False self.app.ui.main_display.sub_displays.network_display.directory_change_callback() @@ -363,9 +933,26 @@ class ConversationsDisplay(): urwid.Divider(g["divider1"]), ]) + action_status = urwid.Text("", align=urwid.CENTER) + ping_button = urwid.Button("Ping") + block_button = urwid.Button("Block") + qr_button = urwid.Button("LXMF") + urwid.connect_signal(ping_button, "click", lambda _b: self._ping_peer_from_dialog(source_hash_text, action_status, ping_button)) + urwid.connect_signal(block_button, "click", lambda _b: self._block_peer_from_dialog(source_hash_text)) + urwid.connect_signal(qr_button, "click", lambda _b: self.show_qr_dialog(source_hash_text, title=display_name or source_hash_text)) + + actions_row = urwid.Columns([ + (urwid.WEIGHT, 0.32, ping_button), + (urwid.WEIGHT, 0.02, urwid.Text("")), + (urwid.WEIGHT, 0.32, block_button), + (urwid.WEIGHT, 0.02, urwid.Text("")), + (urwid.WEIGHT, 0.32, qr_button), + ]) + dialog_pile = urwid.Pile([ selected_id_widget, e_name, + e_copy, urwid.Divider(g["divider1"]), r_untrusted, r_unknown, @@ -373,7 +960,13 @@ class ConversationsDisplay(): urwid.Divider(g["divider1"]), r_direct, r_propagated, + urwid.Divider(g["divider1"]), + cb_pin, + e_notes, known_section, + actions_row, + action_status, + urwid.Divider(g["divider1"]), urwid.Columns([ (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=confirmed)), (urwid.WEIGHT, 0.1, urwid.Text("")), @@ -439,6 +1032,9 @@ class ConversationsDisplay(): self.update_conversation_list() + if trust_level != DirectoryEntry.TRUSTED: + if self.list_filter != ConversationsDisplay.LIST_FILTER_UNTRUSTED: + self._set_filter(ConversationsDisplay.LIST_FILTER_UNTRUSTED) self.display_conversation(source_hash_text) self.dialog_open = False @@ -658,10 +1254,84 @@ class ConversationsDisplay(): self.update_conversation_list() + def _decode_pn_app_data(self, app_data): + try: + if not pn_announce_data_is_valid(app_data): + return None + data = msgpack.unpackb(app_data) + enabled = bool(data[2]) + name = None + try: + meta = data[6] + if isinstance(meta, dict) and PN_META_NAME in meta: + raw = meta[PN_META_NAME] + if isinstance(raw, (bytes, bytearray)): + name = raw.decode("utf-8", errors="replace") + except Exception: + name = None + return {"enabled": enabled, "name": name} + except Exception: + return None + + def _pn_dropdown_label(self, pn_hash, meta): + name = (meta or {}).get("name") + if name: + label = strip_modifiers(name) or "" + label = " ".join(label.split()) + else: + label = "" + if not label: + label = RNS.prettyhexrep(pn_hash) + max_len = 40 + if len(label) > max_len: + label = label[:max_len-1]+"…" + tail = "<"+RNS.hexrep(pn_hash, delimit=False)+">" + if tail not in label: + label = label+" "+tail + if meta is None: + status = "[?]" + elif meta.get("enabled"): + status = "[E]" + else: + status = "[D]" + return status+" "+label + + def _build_pn_options(self): + options = [] + seen = set() + + try: + pn_announces = list(self.app.directory._pn_announces) + except Exception: + pn_announces = [] + for tup in pn_announces: + if len(tup) < 3: continue + pn_hash = tup[1] + app_data = tup[2] + if pn_hash in seen: continue + seen.add(pn_hash) + meta = self._decode_pn_app_data(app_data) + options.append((pn_hash, self._pn_dropdown_label(pn_hash, meta))) + + for extra in (self.app.get_user_selected_propagation_node(), self.app.get_default_propagation_node()): + if extra is None or extra in seen: + continue + seen.add(extra) + meta = None + try: + cached = RNS.Identity.recall_app_data(extra) + if cached is not None: + meta = self._decode_pn_app_data(cached) + except Exception: + meta = None + options.append((extra, self._pn_dropdown_label(extra, meta))) + + return options + def sync_conversations(self): g = self.app.ui.glyphs self.dialog_open = True - + def dismiss_dialog(sender): self.dialog_open = False self.sync_dialog = None @@ -704,36 +1374,58 @@ class ConversationsDisplay(): ]) real_sync_button.bc = button_columns + current_default = self.app.get_default_propagation_node() + user_selected = self.app.get_user_selected_propagation_node() + + pn_options = self._build_pn_options() + + selected_target = user_selected if user_selected is not None else current_default + + def on_pn_picked(picked_hash): + try: + self.app.set_user_selected_propagation_node(picked_hash) + except Exception as e: + RNS.log("Could not update propagation node: "+str(e), RNS.LOG_ERROR) + + if pn_options: + node_picker = PropNodePicker(pn_options, selected_target, on_pn_picked) + node_selector = urwid.Pile([ + urwid.Text("Propagation node:"), + node_picker, + ]) + else: + node_selector = None + pn_ident = None - if self.app.get_default_propagation_node() != None: - pn_hash = self.app.get_default_propagation_node() - pn_ident = RNS.Identity.recall(pn_hash) - - if pn_ident == None: + if current_default is not None: + pn_ident = RNS.Identity.recall(current_default) + if pn_ident is None: RNS.log("Propagation node identity is unknown, requesting from network...", RNS.LOG_DEBUG) - RNS.Transport.request_path(pn_hash) + RNS.Transport.request_path(current_default) - if pn_ident != None: - node_hash = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", pn_ident) - pn_entry = self.app.directory.find(node_hash) - pn_display_str = " " - if pn_entry != None: - pn_display_str += " "+str(pn_entry.display_name) + if pn_ident is not None or node_selector is not None: + header_str = "" + if pn_ident is not None: + node_hash = RNS.Destination.hash_from_name_and_identity("nomadnetwork.node", pn_ident) + pn_entry = self.app.directory.find(node_hash) + if pn_entry is not None and getattr(pn_entry, "display_name", None): + header_str = " "+strip_modifiers(str(pn_entry.display_name)) + else: + header_str = " "+RNS.prettyhexrep(current_default) else: - pn_display_str += " "+RNS.prettyhexrep(pn_hash) + header_str = " (no default)" - dialog = DialogLineBox( - urwid.Pile([ - urwid.Text(""+g["node"]+pn_display_str, align=urwid.CENTER), - urwid.Divider(g["divider1"]), - sync_progress, - urwid.Divider(g["divider1"]), - r_mall, - rbs, - urwid.Text(""), - button_columns - ]), title="Message Sync" - ) + pile_items = [ + urwid.Text(""+g["node"]+header_str, align=urwid.CENTER), + urwid.Divider(g["divider1"]), + sync_progress, + urwid.Divider(g["divider1"]), + ] + if node_selector is not None: + pile_items += [node_selector, urwid.Divider(g["divider1"])] + pile_items += [r_mall, rbs, urwid.Text(""), button_columns] + + dialog = DialogLineBox(urwid.Pile(pile_items), title="Message Sync") else: button_columns = urwid.Columns([ (urwid.WEIGHT, 0.45, urwid.Text("" )), @@ -806,9 +1498,9 @@ class ConversationsDisplay(): self.update_listbox() options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width) - if not (self.dialog_open and self.sync_dialog != None): + if not self.dialog_open: self.columns_widget.contents[0] = (self.listbox, options) - else: + elif self.sync_dialog is not None: bottom = self.listbox overlay = urwid.Overlay( self.sync_dialog, @@ -821,6 +1513,10 @@ class ConversationsDisplay(): right=2, ) self.columns_widget.contents[0] = (overlay, options) + # else: another dialog (peer info, new conversation, block confirm, etc.) is + # open as an overlay in contents[0]; leave it alone so an incoming message + # doesn't dismiss it. The underlying listbox is a persistent widget and was + # already refreshed by update_listbox() above. if selected_hash is not None: for idx, widget in enumerate(self.list_widgets): @@ -848,17 +1544,16 @@ class ConversationsDisplay(): self.app.mark_conversation_read(self.currently_displayed_conversation) self.currently_displayed_conversation = source_hash - # options = self.widget.options(urwid.WEIGHT, 1-ConversationsDisplay.list_width) - options = self.widget.options(urwid.WEIGHT, 1) - self.widget.contents[1] = (self.make_conversation_widget(source_hash), options) + options = self.columns_widget.options(urwid.WEIGHT, 1) + self.columns_widget.contents[1] = (self.make_conversation_widget(source_hash), options) if source_hash == None: - self.widget.focus_position = 0 + self.columns_widget.focus_position = 0 else: if self.app.conversation_is_unread(source_hash): self.app.mark_conversation_read(source_hash) self.update_conversation_list() - self.widget.focus_position = 1 + self.columns_widget.focus_position = 1 conversation_position = None index = 0 for widget in self.list_widgets: @@ -907,6 +1602,7 @@ class ConversationsDisplay(): source_hash = conversation[0] unread = conversation[4] last_activity = conversation[5] + failed = conversation[6] if len(conversation) > 6 else 0 g = self.app.ui.glyphs @@ -920,8 +1616,8 @@ class ConversationsDisplay(): focus_style = "list_focus" elif trust_level == DirectoryEntry.TRUSTED: symbol = g["check"] - style = "list_trusted" - focus_style = "list_focus_trusted" + style = "body_text" + focus_style = "list_focus" elif trust_level == DirectoryEntry.WARNING: symbol = g["warning"] style = "list_warning" @@ -931,27 +1627,35 @@ class ConversationsDisplay(): style = "list_untrusted" focus_style = "list_focus_untrusted" - display_text = symbol + is_pinned = False + try: + entry = self.app.directory.find(bytes.fromhex(source_hash)) + is_pinned = entry is not None and entry.sort_rank is not None + except Exception: + is_pinned = False + + head = symbol + if is_pinned: + head = g.get("pin", "*") + " " + head if display_name != None and display_name != "": - display_text += " "+display_name + head += " "+display_name if trust_level != DirectoryEntry.TRUSTED: - display_text += " <"+source_hash+">" - - if trust_level != DirectoryEntry.UNTRUSTED: - if unread: - if source_hash != self.currently_displayed_conversation: - if unread > 1: - display_text += " "+g["unread"]+" ("+str(unread)+")" - else: - display_text += " "+g["unread"] + head += " <"+source_hash+">" + markup = [head] + if failed and source_hash != self.currently_displayed_conversation: + badge_text = " "+g["warning"]+" ("+str(failed)+")" + markup.append(("msg_header_caution", badge_text)) + elif unread and source_hash != self.currently_displayed_conversation: + badge_text = " "+g["unread"]+" ("+str(unread)+")" + markup.append(("msg_header_delivered", badge_text)) if last_activity > 0: - display_text += "\n "+relative_time(last_activity) + markup.append("\n "+relative_time(last_activity)) - widget = ListEntry(display_text) + widget = ListEntry(markup) urwid.connect_signal(widget, "click", self.display_conversation, conversation[0]) display_widget = urwid.AttrMap(widget, style, focus_style) display_widget.source_hash = source_hash @@ -961,7 +1665,12 @@ class ConversationsDisplay(): def shortcuts(self): - focus_path = self.widget.get_focus_path() + try: + focus_path = self.columns_widget.get_focus_path() + except Exception: + return self.list_shortcuts + if not focus_path: + return self.list_shortcuts if focus_path[0] == 0: return self.list_shortcuts elif focus_path[0] == 1: @@ -1022,10 +1731,26 @@ class MessageEdit(urwid.Edit): class ConversationFrame(urwid.Frame): def keypress(self, size, key): + if self.focus_position == "header": + result = super(ConversationFrame, self).keypress(size, key) + if result == "up": + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" + return None + if result == "down": + self.focus_position = "body" + return None + return result if self.focus_position == "body": if getattr(self.delegate, "dialog_active", False) or getattr(self.delegate, "dialog_open", False): return super(ConversationFrame, self).keypress(size, key) elif key == "up" and self.delegate.messagelist.top_is_visible: + if getattr(self.delegate, "has_visible_trust_banner", lambda: False)(): + try: + self.delegate._header_pile.focus_position = 1 + self.focus_position = "header" + return None + except Exception: + pass nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" elif key == "down" and self.delegate.messagelist.bottom_is_visible: self.focus_position = "footer" @@ -1073,14 +1798,10 @@ class ConversationWidget(urwid.WidgetWrap): self.peer_info_widget = urwid.AttrMap(urwid.Text(""), "msg_header_sent") self._update_peer_info() - header_widgets = [self.peer_info_widget] - if self.conversation.trust_level == DirectoryEntry.UNTRUSTED: - header_widgets.append(urwid.AttrMap( - urwid.Padding( - urwid.Text(g["warning"]+" Warning: Conversation with untrusted peer "+g["warning"], align=urwid.CENTER)), - "msg_warning_untrusted", - )) - header = urwid.Pile(header_widgets) + self._trust_banner_dismissed = False + self._header_pile = urwid.Pile([self.peer_info_widget]) + self._refresh_trust_banner() + header = self._header_pile self.minimal_editor = urwid.AttrMap(msg_editor, "msg_editor") self.minimal_editor.name = "minimal_editor" @@ -1119,6 +1840,137 @@ class ConversationWidget(urwid.WidgetWrap): super().__init__(self.display_widget) + def has_visible_trust_banner(self): + if self._trust_banner_dismissed: + return False + try: + tl = self.app.directory.trust_level(bytes.fromhex(self.source_hash)) + except Exception: + tl = DirectoryEntry.UNKNOWN + return tl != DirectoryEntry.TRUSTED + + def _refresh_trust_banner(self): + contents = [(self.peer_info_widget, self._header_pile.options())] + if self.has_visible_trust_banner(): + banner = self._build_trust_banner() + contents.append((banner, self._header_pile.options())) + self._header_pile.contents = contents + if len(contents) > 1: + try: self._header_pile.focus_position = 1 + except Exception: pass + + def _build_trust_banner(self): + g = self.app.ui.glyphs + msg = urwid.Text(" "+g["warning"]+" This peer isn't trusted yet.") + btn_trust = urwid.Button("Trust", on_press=self._on_trust_click) + btn_block = urwid.Button("Block", on_press=self._on_block_click) + btn_nothing = urwid.Button("Do nothing", on_press=self._on_ignore_click) + row = urwid.Columns([ + ('weight', 1, msg), + ('pack', btn_trust), + (1, urwid.Text(" ")), + ('pack', btn_block), + (1, urwid.Text(" ")), + ('pack', btn_nothing), + (1, urwid.Text(" ")), + ], dividechars=0) + return urwid.AttrMap(row, "msg_warning_untrusted") + + def _on_trust_click(self, _btn): + try: + src = bytes.fromhex(self.source_hash) + existing = self.app.directory.find(src) + display_name = getattr(existing, "display_name", None) if existing is not None else None + preferred = getattr(existing, "preferred_delivery", None) if existing is not None else None + entry = DirectoryEntry(src, display_name, DirectoryEntry.TRUSTED, preferred_delivery=preferred) + self.app.directory.remember(entry) + except Exception as e: + RNS.log("Could not mark peer as trusted: "+str(e), RNS.LOG_ERROR) + self._refresh_trust_banner() + try: + self.frame.focus_position = "footer" + except Exception: + pass + try: + if self.delegate.list_filter != ConversationsDisplay.LIST_FILTER_TRUSTED: + self.delegate._set_filter(ConversationsDisplay.LIST_FILTER_TRUSTED) + else: + self.delegate.update_conversation_list() + except Exception as e: + RNS.log("Trust UI refresh failed: "+str(e), RNS.LOG_ERROR) + + def _on_ignore_click(self, _btn): + self._trust_banner_dismissed = True + self._refresh_trust_banner() + + def _on_block_click(self, _btn): + def dismiss(_b): + self.dialog_active = False + try: self.delegate.dialog_open = False + except Exception: pass + try: self.delegate.update_conversation_list() + except Exception: pass + + def confirmed(_b): + self.dialog_active = False + try: self.delegate.dialog_open = False + except Exception: pass + try: + self._block_peer() + except Exception as e: + RNS.log("Block failed: "+str(e), RNS.LOG_ERROR) + try: self.delegate.update_conversation_list() + except Exception: pass + + try: + who = self.app.directory.simplest_display_str(bytes.fromhex(self.source_hash)) + except Exception: + who = self.source_hash + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text(""), + urwid.Text("Block "+str(who)+"?\n\nThis will blackhole the peer's identity in Reticulum,\nadd them to your ignored list, and delete this conversation.\n", align=urwid.CENTER), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes, block", on_press=confirmed)), + (urwid.WEIGHT, 0.10, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=dismiss)), + ]), + ]), title="Confirm block" + ) + dialog.delegate = self.delegate + + bottom = self.delegate.listbox + overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=urwid.RELATIVE_100, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2) + try: + self.delegate.columns_widget.contents[0] = ( + overlay, + self.delegate.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width), + ) + self.delegate.columns_widget.focus_position = 0 + self.dialog_active = True + try: self.delegate.dialog_open = True + except Exception: pass + except Exception: + pass + + def _block_peer(self): + try: + src = bytes.fromhex(self.source_hash) + except Exception: + return + + try: + self.app.block_destination(src, reason="user-blocked from nomadnet conversation") + except Exception as e: + RNS.log("Block failed: "+str(e), RNS.LOG_ERROR) + + try: + self.delegate.delete_conversation(self.source_hash) + nomadnet.Conversation.delete_conversation(self.source_hash, self.app) + except Exception as e: + RNS.log("Could not delete blocked conversation: "+str(e), RNS.LOG_ERROR) + def _update_peer_info(self): def san(name): if self.app.config["textui"]["sanitize_names"]: return sanitize_name(name) @@ -1669,6 +2521,15 @@ class LXMessageWidget(urwid.WidgetWrap): if message.get_title() != "": title_string += " | " + message.get_title() + inbound_untrusted = False + if not is_outbound and msg_source_hash is not None: + try: + sender_trust = app.directory.trust_level(msg_source_hash) + if sender_trust in (DirectoryEntry.UNTRUSTED, DirectoryEntry.WARNING, DirectoryEntry.UNKNOWN): + inbound_untrusted = True + except Exception: + inbound_untrusted = False + has_attachments = message.has_attachments() cached_names = message._cached_attachment_names or [] @@ -1714,6 +2575,12 @@ class LXMessageWidget(urwid.WidgetWrap): pile_widgets.append(urwid.Text(indented)) if has_attachments and cached_names: + if inbound_untrusted: + pile_widgets.append(urwid.AttrMap( + urwid.Text(" "+g["warning"]+" This attachment came from a peer that's untrusted. Be careful when opening it."), + "list_untrusted", + )) + att_file_idx = 0 for atype, aname, *arest in cached_names: glyph = g["file"] if atype == "file" else g[atype] diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 840dcc8..712b2a1 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -60,6 +60,47 @@ class SelectText(urwid.Text): self._emit('click') return True +def _rows_above(attrmaps, index, cols): + if index <= 0 or not attrmaps: + return 0 + total = 0 + for i in range(min(index, len(attrmaps))): + try: + total += attrmaps[i].rows((cols,)) + except Exception: + total += 1 + return total + + +class GuideLinkDelegate: + def __init__(self, app, reader=None): + self.app = app + self.reader = reader + self.last_keypress = 0 + + def marked_link(self, target, fields=None): + pass + + def micron_released_focus(self): + pass + + def handle_link(self, target, fields=None): + if not target: + return + if target.startswith("#"): + if self.reader is not None: + try: + self.reader.jump_to_anchor(target[1:]) + except Exception as e: + RNS.log("Guide anchor jump failed: "+str(e), RNS.LOG_ERROR) + return + try: + self.app.ui.main_display.show_network(None) + self.app.ui.main_display.sub_displays.network_display.browser.handle_link(target, fields) + except Exception as e: + RNS.log("Could not open guide link: "+str(e), RNS.LOG_ERROR) + + class GuideEntry(urwid.WidgetWrap): def __init__(self, app, parent, reader, topic_name): self.app = app @@ -79,7 +120,7 @@ class GuideEntry(urwid.WidgetWrap): def display_topic(self, event, topic): markup = TOPICS[topic] - attrmaps = markup_to_attrmaps(markup, url_delegate=None) + attrmaps = markup_to_attrmaps(markup, url_delegate=GuideLinkDelegate(self.app, reader=self.reader)) topic_position = None index = 0 @@ -109,6 +150,7 @@ class TopicList(urwid.WidgetWrap): self.topic_list = [ GuideEntry(self.app, self, guide_display, "Introduction"), GuideEntry(self.app, self, guide_display, "Concepts & Terminology"), + GuideEntry(self.app, self, guide_display, "Channels & RRC"), GuideEntry(self.app, self, guide_display, "Interfaces"), GuideEntry(self.app, self, guide_display, "Hosting a Node"), GuideEntry(self.app, self, guide_display, "Configuration Options"), @@ -166,10 +208,53 @@ class GuideDisplay(): def set_content_widgets(self, new_content): options = self.columns.options(width_type=urwid.WEIGHT, width_amount=1-GuideDisplay.list_width, box_widget=True) pile = urwid.Pile(new_content) - content = urwid.LineBox(urwid.AttrMap(ScrollBar(Scrollable(pile), thumb_char="\u2503", trough_char=" "), "scrollbar")) + self._content_attrmaps = new_content + self._content_scrollable = Scrollable(pile) + content = urwid.LineBox(urwid.AttrMap(ScrollBar(self._content_scrollable, thumb_char="\u2503", trough_char=" "), "scrollbar")) self.columns.contents[1] = (content, options) + def jump_to_anchor(self, name): + scrollable = getattr(self, "_content_scrollable", None) + attrmaps = getattr(self, "_content_attrmaps", None) + if scrollable is None or attrmaps is None: + return + anchors = getattr(attrmaps, "anchors", None) or {} + header_rows = getattr(attrmaps, "header_rows", None) or [] + cols = self._content_cols() + target_idx = None + if name: + target_idx = anchors.get(name) + if target_idx is None: + return + else: + try: current = scrollable.get_scrollpos() + except Exception: current = 0 + for hr in header_rows: + if _rows_above(attrmaps, hr, cols) > current: + target_idx = hr + break + if target_idx is None: + return + row_offset = _rows_above(attrmaps, target_idx, cols) + try: + scrollable.set_scrollpos(int(row_offset)) + except Exception: + pass + + def _content_cols(self): + try: + cols = self.app.ui.loop.screen.get_cols_rows()[0] + except Exception: + cols = 100 + try: + widths = self.columns.column_widths((cols,)) + if len(widths) > 1 and widths[1] > 0: + return max(40, widths[1] - 3) + except Exception: + pass + return max(40, int(cols * (1 - GuideDisplay.list_width)) - 3) + def shortcuts(self): return self.shortcuts_display @@ -191,7 +276,7 @@ Nomad Network is build on LXMF and Reticulum, which together provides the crypto Nomad Network does not need any connections to the public internet to work. In fact, it doesn't even need an IP or Ethernet network. You can use it entirely over packet radio, LoRa or even serial lines. But if you wish, you can bridge islanded Reticulum networks over the Internet or private ethernet networks, or you can build networks running completely over the Internet. The choice is yours. -The current version of the program should be considered a beta release. The program works well, but there will most probably be bugs and possibly sub-optimal performance in some scenarios. On the other hand, this is the best time to have an influence on the direction of the development of Nomad Network. To do so, join the discussion on the Nomad Network project on GitHub. +The current version of the program should be considered a beta release. The program works well, but there will most probably be bugs and possibly sub-optimal performance in some scenarios. On the other hand, this is the best time to have an influence on the direction of the development of Nomad Network. To do so, join the discussion on the Nomad Network project at `_`F00f`[Aleph git`a8d24177d946de4f1f0a0fe1af9a1338:/page/index.mu]`f`_. ''' @@ -612,6 +697,61 @@ Conversations in Nomad Network ''' +TOPIC_CHANNELS = '''>Channels & RRC + +NomadNet includes a built-in client for `*RRC`* (Reticulum Relay Chat), a real-time text chat protocol that runs over Reticulum. The reference RRC server implementation lives at https://github.com/kc1awv/rrcd. Each RRC server is called a `!hub`!, and each hub hosts one or more `!rooms`! (channels) you can join. Hubs are addressed by a Reticulum destination hash, just like nodes and conversations. + +The Channels section of NomadNet (accessible from the main menu, or by pressing the corresponding shortcut) is where you manage your hubs, view connection status, and chat in rooms. + +>>Joining a hub + +To start chatting on a hub you must first add it to your hub list: + +>>> + - Open the `![ Channels ]`! section. + - Press the shortcut to open the `*New Hub`* dialog (shown in the shortcut bar at the bottom). + - Enter the `!hub address`! (the destination hash of the hub, typically 16 hex characters / 8 bytes) and an optional display name. + - Confirm the dialog. The hub will appear in your hub list. +< + +You can sometimes receive a hub link from another user or from a node page. Activating such a link will pre-fill the New Hub dialog (and optionally a room name) for you. + +>>Connecting and listing rooms + +Once a hub is added: + +>>> + - Select the hub in the list and connect to it. Hubs can also be set to auto-reconnect. + - When connected, run `*/list`* to see public rooms hosted on this hub. + - Run `*/join `* to join a room. Joined rooms appear under the hub in your channel list. +< + +>>Chatting + +Inside a room, anything you type that does not start with a `*/`* is sent as a message to everyone in the room. The most common in-room commands are: + +>>> + - `*/help`* show the full list of commands + - `*/who`* list users in the current room + - `*/nick `* set your display name on this hub + - `*/topic [text]`* view or set the room topic + - `*/part [room]`* leave a room (defaults to the current one) + - `*/quit`* disconnect from the hub +< + +Messages support both `*markdown`* and `*micron`* formatting, depending on your render settings (see the `*Configuration Options`* topic). Because the backtick is the micron control character, you must use the `!broken-bar`! character `*¦`* in place of `*\\``* when typing micron formatting in a message. The renderer converts `*¦`* back to `*\\``* for display. + +>>Mentions and privacy + +You will be notified (and the bell may ring, depending on your configuration) when another user mentions your nick with `*@yournick`*. RRC traffic is end-to-end encrypted by Reticulum between you and the hub, but other users in the same room can read what you write there. Treat rooms as semi-public spaces. + +>>Hub etiquette + +Each hub is operated independently and may have its own rules, MOTD, ban list and operator team. Be respecful, follow the hub's MOTD, and remember that operators can kick, ban and set modes on rooms they own. + +''' + + TOPIC_FIRST_RUN = '''>First Time Information Hi there. This first run message will only appear once. It contains a few pointers on getting started with Nomad Network, and getting the most out of the program. @@ -622,7 +762,33 @@ To get the most out of Nomad Network, you will need a terminal that supports UTF It is recommended to use a terminal size of at least 135x32. Nomad Network will work with smaller terminal sizes, but the interface might feel a bit cramped. -If you don't already have a Nerd Font installed (see https://www.nerdfonts.com/), I also highly recommend to do so, since it will greatly expand the amount of glyphs, icons and graphics that Nomad Network can use. Once you have your terminal set up with a Nerd Font, go to the `![ Config ]`! menu item and enable Nerd Fonts in the configuration instead of normal unicode glyphs. +Nerd Fonts and true-color are enabled by default. To get the full visual experience you should have a Nerd Font installed in your terminal. You can verify this by visiting the `*Display Test`* topic in this guide and checking the `!Nerd Font Rendering Test`! section. If those glyphs render correctly, you are good to go. + +If they do not render, install a Nerd Font and configure your terminal to use it. A few common approaches: + +>>> + - `!Download from nerdfonts.com`! + Visit https://www.nerdfonts.com/font-downloads, pick a font you like + (Jet Brains Mono, Hack and Meslo are popular choices), unzip it + into `*~/.local/share/fonts`* (Linux) or install via Font Book (macOS), + then refresh the font cache with `*fc-cache -fv`* on Linux. + + - `!Package manager`! + Many distros ship Nerd Font packages. On Arch: `*pacman -S ttf-nerd-fonts-symbols`* + or one of the per-family packages. On Debian/Ubuntu look for `*fonts-firacode`* + plus the symbols-only Nerd Font package, or install manually from the + nerdfonts.com release. + + - `!Homebrew (macOS)`! + `*brew install --cask font-fira-code-nerd-font`* (or any other family). + + - `!Configure your terminal`! + After installing, set your terminal emulator's font to the Nerd Font + variant (it usually has "Nerd Font" in the name). Restart NomadNet + so the new font are picked up. +< + +If for some reason you cannot or do not want to use a Nerd Font, you can disable Nerd Font glyphs in the `![ Config ]`! menu and NomadNet will fall back to plain unicode symbols. Nomad Network expects that you are already connected to some form of Reticulum network. That could be as simple as the default one that Reticulum auto-generates on your local ethernet/WiFi network, or something much more complex. This short guide won't go into any details on building networks, but you will find other entries in the guide that deal with network setup and configuration. @@ -1073,6 +1239,23 @@ The following line should contain a grayscale gradient bar: Unicode Glyphs : \u2713 \u2715 \u26a0 \u24c3 \u2193 Nerd Font Glyphs : \uf484 \U000f04c5 \U000f0219 \U000f0002 \uf415 \uf023 \uf06e + + +>>Nerd Font Rendering Test + +`cSince Nerd Font glyphs and true-color are enabled by default, the rows below should render as crisp icons rather than empty squares, question marks, or fallback ASCII. If any glyph appears blank or boxed, your terminal is not using a Nerd Font, or the font is missing the required glyph range. +`` + +>>> +Common UI icons : \uf484 \uf415 \uf023 \uf06e \uf055 \uf056 \uf059 +Status / state : \U000f04c5 \U000f0219 \U000f0002 \U000f1397 \U000f12fc \U000f0156 +Network / radio : \U000f05a9 \U000f05aa \U000f05ab \U000f05ac \uf1eb \uf012 +People / messaging: \uf0c0 \uf007 \uf2bd \uf0e0 \uf27a \uf086 +Devices / files : \uf233 \uf07b \uf15b \uf019 \uf093 \uf1c0 +< + +`cIf the above renders correctly, you have a working Nerd Font setup and can leave the defaults as-is. If not, see the `*First Time Information`* topic for install instructions. +`` ''' @@ -1131,6 +1314,22 @@ Nomad Network supports a simple and functional markup language called `*micron`* With micron you can easily create structured documents and pages with formatting, colors, glyphs and icons, ideal for display in terminals. +>Table of Contents + +`F00f`_`[A Few Demo Outputs`#a-few-demo-outputs]`_`f +`F00f`_`[Micron Tags`#micron-tags]`_`f +`F00f`_`[High Level Stuff`#high-level-stuff]`_`f +`F00f`_`[Colors`#colors]`_`f +`F00f`_`[Page Foreground and Background Colors`#page-foreground-and-background-colors]`_`f +`F00f`_`[Links`#links]`_`f +`F00f`_`[Anchors`#anchors]`_`f +`F00f`_`[Tables`#tables]`_`f +`F00f`_`[Fields & Requests`#fields-requests]`_`f +`F00f`_`[Comments`#comments]`_`f +`F00f`_`[Partials`#partials]`_`f +`F00f`_`[Literals`#literals]`_`f +`F00f`_`[Closing Remarks`#closing-remarks]`_`f + >>Recommendations and Requirements While micron can output formatted text to even the most basic terminal, there's a few capabilities your terminal `*must`* support to display micron output correctly, and some that, while not strictly necessary, make the experience a lot better. @@ -1342,6 +1541,55 @@ Here is `F00f`_`[a more visible link`72914442a3689add83a09a767963f57c:/page/inde When links like these are displayed in the built-in browser, clicking on them or activating them using the keyboard will cause the browser to load the specified URL. +>Anchors + +Anchors let you create jump points within a single page similar to anchors in HTML. You declare a position in the page with a name, then link to it from anywhere on the same page. + +>>Auto-anchors from headers + +Every section heading you write also becomes an anchor automatically. The anchor name is the heading text after `*slugifying`*: lowercased, with any run of non-alphanumeric characters replaced by a single hyphen, and leading or trailing hyphens stripped. So `*>Hello World`* becomes the anchor `*hello-world`*, and `*>Introduction & Setup`* becomes `*introduction-setup`*. + +>>Explicit anchors + +If you want an anchor, that isn't tied to a heading, place one anywhere in your text with the \\`: tag, followed by a name. Names may contain the characters `*A-Z`*, `*a-z`*, `*0-9`*, `*_`* and `*-`*, and end at any other character (a space, a newline, or punctuation). + +The anchor itself takes up no space and does not render. Is's just a position marker. An explicit anchor declared on an otherwise empty line binds to that line's position. + +`Faaa +`= +`:install-notes +Some installation notes for the user. + +You can also drop one mid-line. Example: see `:tip-3 ⚠ tip 3 below for caveats. +`= +`` + +>>Linking to an anchor + +Reuse the standard link syntax, with a `*#`*-prefixed URL: + +`Faaa +`= +`[Jump to Install Notes`#install-notes] +`= +`` + +When the user activates the link the browser scrolls the current page to the anchor's row. + +>>Jumping to the next section + +If the URL is just `*#`* with no name after it, the link jumps to the next \\`> header that appears after the link's own position in the document. This is convenient for "Continue ↓" buttons after a long paragraph, without having to name every section: + +`Faaa +`= +`[Continue`#] +`= +`` + +>>Notes on namespaces and collisions + +Auto-anchors from headings and explicit \\`: anchors share a single namespace per page. If an explicit anchor collides with a heading slug, the first one declared is where it will jump to. + >Tables You can include rendered tables by enclosing them in \\`t tags. Optionally, you can also specify alignment and max rendering width by adding these properties to the opening \\`t tag, like \\`tc30. Here's an example: @@ -1564,6 +1812,7 @@ TOPIC_MARKUP += "\n`=\n\n>Closing Remarks\n\nIf you made it all the way here, yo TOPICS = { "Introduction": TOPIC_INTRODUCTION, "Concepts & Terminology": TOPIC_CONCEPTS, + "Channels & RRC": TOPIC_CHANNELS, "Conversations": TOPIC_CONVERSATIONS, "Interfaces": TOPIC_INTERFACES, "Hosting a Node": TOPIC_HOSTING, diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index bf1ed30..90a3ad7 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -1,6 +1,7 @@ import nomadnet import urwid import random +import re import time import RNS from urwid.util import is_mouse_press @@ -57,10 +58,32 @@ def default_state(fg=None, bg=None): "align": "left", "default_fg": fg, "default_bg": bg, + "anchors": {}, + "pending_anchors": [], + "header_rows": [], } return state -def markup_to_attrmaps(markup, url_delegate = None, fg_color=None, bg_color=None, link_class=None): + +_MICRON_STRIP_RE = re.compile( + r"`[FB]T[0-9a-fA-F]{6}" + r"|`[FB][0-9a-fA-F]{3}" + r"|`:[A-Za-z0-9_\-]*" + r"|`[!*_=fbacrl`<>{]" +) + +def slugify_micron(text): + if text is None: return "" + stripped = _MICRON_STRIP_RE.sub("", text) + s = re.sub(r"[^A-Za-z0-9]+", "-", stripped).strip("-").lower() + return s + +class _AttrMapList(list): + """list subclass that allows attaching parser metadata like `anchors`.""" + pass + + +def markup_to_attrmaps(markup, url_delegate = None, fg_color=None, bg_color=None, link_class=None, anchors=None): global LINK_CLASS if link_class: LINK_CLASS = link_class else: LINK_CLASS = LinkableText @@ -71,13 +94,17 @@ def markup_to_attrmaps(markup, url_delegate = None, fg_color=None, bg_color=None else: SELECTED_STYLES = STYLES_LIGHT - attrmaps = [] + attrmaps = _AttrMapList() fgc = None; bgc = DEFAULT_BG if bg_color != None: bgc = bg_color if fg_color != None: fgc = fg_color state = default_state(fgc, bgc) + if anchors is None: + anchors = state["anchors"] + else: + state["anchors"] = anchors # Split entire document into lines for # processing. @@ -88,12 +115,30 @@ def markup_to_attrmaps(markup, url_delegate = None, fg_color=None, bg_color=None display_widgets = parse_line(line, state, url_delegate) else: display_widgets = [urwid.Text("")] - + if display_widgets != None and len(display_widgets) != 0: + row_index = len(attrmaps) + pending = state.get("pending_anchors") or [] + if pending: + for name in pending: + if name and name not in anchors: + anchors[name] = row_index + state["pending_anchors"] = [] + + if state.get("_header_pending"): + state["header_rows"].append(row_index) + state["_header_pending"] = False + for display_widget in display_widgets: attrmap = urwid.AttrMap(display_widget, make_style(state)) attrmaps.append(attrmap) + try: + setattr(attrmaps, "anchors", anchors) + setattr(attrmaps, "header_rows", list(state.get("header_rows", []))) + except Exception: + pass + return attrmaps def parse_partial(line): @@ -239,7 +284,7 @@ def parse_line(line, state, url_delegate): while i < len(line) and line[i] == ">": i += 1 state["depth"] = i - + for j in range(1, i+1): wanted_style = "heading"+str(i) if wanted_style in SELECTED_STYLES: @@ -252,9 +297,14 @@ def parse_line(line, state, url_delegate): heading_style = make_style(state) output = make_output(state, line, url_delegate) - + style_to_state(latched_style, state) + slug = slugify_micron(line) + if slug: + state.setdefault("pending_anchors", []).append(slug) + state["_header_pending"] = True + if len(output) > 0: first_style = output[0][0] @@ -599,6 +649,18 @@ def make_output(state, line, url_delegate, pre_escape=False): elif c == "a": state["align"] = state["default_align"] + elif c == ":": + # Anchor declaration: `:anchor-name (terminated by any non-name char). The anchor is a zero width position narker bound to the current line by markup_to_attrmaps + name_start = i + 1 + name_end = name_start + while name_end < len(line) and (line[name_end].isalnum() or line[name_end] in "_-"): + name_end += 1 + anchor_name = line[name_start:name_end] + if anchor_name: + state.setdefault("pending_anchors", []).append(anchor_name) + skip = (name_end - i) - 1 + if skip < 0: skip = 0 + elif c == '<': if len(part) > 0: output.append(make_part(state, part))