mirror of
https://github.com/markqvist/NomadNet.git
synced 2026-05-26 11:56:17 +00:00
Merged The Deluxe Nomadnet Expansion Pack from Zenith
This commit is contained in:
+43
-13
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -546,6 +546,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)
|
||||
@@ -707,16 +753,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:
|
||||
|
||||
+12
-1
@@ -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:
|
||||
|
||||
@@ -163,6 +163,7 @@ GLYPHS = {
|
||||
("file", "[F]", "\u25a4", "\uf15b"),
|
||||
("image", "[I]", "\u25a3", "\uf1c5"),
|
||||
("audio", "[~]", "\u266b", "\uf1c7"),
|
||||
("pin", "*", "\u2605", "\uf08d"),
|
||||
}
|
||||
|
||||
class TextUI:
|
||||
|
||||
@@ -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://<hex>[:<dest_name>]/<room> 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):
|
||||
|
||||
+386
-33
@@ -15,6 +15,7 @@ from RNS.Utilities.rngit.highlight import SyntaxHighlighter
|
||||
from .MicronParser import markup_to_attrmaps
|
||||
from nomadnet.util import sanitize_name, strip_modifiers, strip_micron
|
||||
from nomadnet.util import strip_escaped_micron, unescape_micron, strip_non_formatting_tags
|
||||
from nomadnet.vendor.Scrollable import Scrollable, ScrollBar
|
||||
|
||||
|
||||
theme_dark = { "text": "ddd",
|
||||
@@ -180,13 +181,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):
|
||||
@@ -214,7 +215,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()
|
||||
@@ -230,6 +325,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()):
|
||||
@@ -261,6 +362,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)
|
||||
|
||||
|
||||
@@ -283,6 +390,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:
|
||||
@@ -367,11 +478,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"
|
||||
@@ -415,8 +546,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
|
||||
@@ -437,35 +571,75 @@ 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:
|
||||
name = sanitize_name(name); name = name[:15]+"…" if len(name) > 16 else name
|
||||
entries = []
|
||||
for m in members:
|
||||
safe_name = _safe_name(self.hub.display_name_for(m))
|
||||
safe_name = safe_name[:15]+"…" if len(safe_name) > 16 else safe_name
|
||||
entries.append((safe_name, 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:
|
||||
@@ -491,8 +665,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")])]
|
||||
@@ -508,7 +696,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):
|
||||
@@ -559,7 +747,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):
|
||||
@@ -909,6 +1097,32 @@ class _ChatLinkDelegate:
|
||||
except Exception as e:
|
||||
RNS.log("Could not open page link: "+str(e), RNS.LOG_ERROR)
|
||||
|
||||
|
||||
_MOTD_ROOM_RE = re.compile(r"(?<!\[)(?<!\w)#([A-Za-z0-9][A-Za-z0-9_\-]{0,62})")
|
||||
|
||||
def _linkify_motd(text):
|
||||
if not text:
|
||||
return text or ""
|
||||
def repl(m):
|
||||
name = m.group(1)
|
||||
return "`["+m.group(0)+"`room://"+name+"]"
|
||||
return _MOTD_ROOM_RE.sub(repl, text)
|
||||
|
||||
|
||||
def _is_joinpart_system(m):
|
||||
if getattr(m, "kind", None) != "system":
|
||||
return False
|
||||
text = (getattr(m, "text", "") or "").strip()
|
||||
if not text:
|
||||
return False
|
||||
if text.startswith("You "):
|
||||
return False
|
||||
return text.endswith(" joined") or text.endswith(" left")
|
||||
|
||||
def _collapsed_joinpart_widget(app, n):
|
||||
label = " ⋯ "+str(n)+" join/leave event"+("" if n == 1 else "s")+" ⋯"
|
||||
return urwid.Padding(urwid.AttrMap(urwid.Text(label, align=urwid.CENTER), "irc_system"), left=1)
|
||||
|
||||
def get_nick_color(sender_hash, theme, app, shift=15):
|
||||
if app.rrc_nick_colors_theme: nick_colors = app.rrc_nick_colors_theme
|
||||
else: nick_colors = theme["nick_colors"]
|
||||
@@ -1044,8 +1258,11 @@ class ChannelsDisplay():
|
||||
self.selected_key = None
|
||||
self.current_room_widget = None
|
||||
self.users_visible = True
|
||||
self.channel_list_visible = True
|
||||
self.collapse_join_part = False
|
||||
|
||||
self._build_listbox()
|
||||
self.gutter = ChannelsExpandGutter(self.app, self)
|
||||
|
||||
self.list_shortcuts = ChannelsListShortcuts(self.app)
|
||||
self.room_shortcuts = ChannelsRoomShortcuts(self.app)
|
||||
@@ -1075,6 +1292,44 @@ class ChannelsDisplay():
|
||||
self.app.rrc.set_change_callback(self._on_rrc_change)
|
||||
self.app.rrc.set_message_callback(self._on_rrc_message)
|
||||
|
||||
def _set_right_widget(self, widget):
|
||||
self.right = widget
|
||||
self._apply_channel_list_visibility(focus_right=True)
|
||||
|
||||
def toggle_channel_list(self):
|
||||
if self.channel_list_visible and self.right is self.placeholder:
|
||||
return
|
||||
self.channel_list_visible = not self.channel_list_visible
|
||||
self._apply_channel_list_visibility()
|
||||
|
||||
def toggle_join_part_collapse(self):
|
||||
self.collapse_join_part = not self.collapse_join_part
|
||||
if self.current_room_widget is not None:
|
||||
try:
|
||||
self.current_room_widget.update_messages(replace=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _apply_channel_list_visibility(self, focus_right=False):
|
||||
list_opts = self.columns_widget.options(urwid.GIVEN, ChannelsDisplay.given_list_width)
|
||||
gutter_opts = self.columns_widget.options(urwid.GIVEN, 1)
|
||||
right_opts = self.columns_widget.options(urwid.WEIGHT, 1)
|
||||
if self.channel_list_visible:
|
||||
self.columns_widget.contents = [
|
||||
(self.listbox, list_opts),
|
||||
(self.right, right_opts),
|
||||
]
|
||||
if focus_right:
|
||||
try: self.columns_widget.focus_position = 1
|
||||
except Exception: pass
|
||||
else:
|
||||
self.columns_widget.contents = [
|
||||
(self.gutter, gutter_opts),
|
||||
(self.right, right_opts),
|
||||
]
|
||||
try: self.columns_widget.focus_position = 1
|
||||
except Exception: pass
|
||||
|
||||
def start(self):
|
||||
self.update_list()
|
||||
|
||||
@@ -1083,7 +1338,7 @@ class ChannelsDisplay():
|
||||
focus_path = self.columns_widget.get_focus_path()
|
||||
except Exception:
|
||||
focus_path = None
|
||||
if focus_path and focus_path[0] == 1:
|
||||
if focus_path and focus_path[0] == 1 and self.current_room_widget is not None:
|
||||
return self.room_shortcuts
|
||||
return self.list_shortcuts
|
||||
|
||||
@@ -1175,8 +1430,8 @@ class ChannelsDisplay():
|
||||
self.listbox = ChannelsListArea(urwid.Filler(self.ilb, height=urwid.RELATIVE_100), title="Channels")
|
||||
self.listbox.delegate = self
|
||||
|
||||
options = self.columns_widget.options(urwid.GIVEN, ChannelsDisplay.given_list_width)
|
||||
if not self.dialog_open:
|
||||
if not self.dialog_open and self.channel_list_visible:
|
||||
options = self.columns_widget.options(urwid.GIVEN, ChannelsDisplay.given_list_width)
|
||||
self.columns_widget.contents[0] = (self.listbox, options)
|
||||
|
||||
if prev_key is not None:
|
||||
@@ -1291,6 +1546,17 @@ class ChannelsDisplay():
|
||||
else:
|
||||
lines.append(urwid.Text(" Use Ctrl-R to connect."))
|
||||
|
||||
if hub.motd:
|
||||
lines.append(urwid.Divider(g["divider1"]))
|
||||
lines.append(urwid.Text(" MOTD:"))
|
||||
motd_delegate = _ChatLinkDelegate(self, hub)
|
||||
try:
|
||||
motd_widgets = markup_to_attrmaps(_linkify_motd(hub.motd), url_delegate=motd_delegate)
|
||||
except Exception:
|
||||
motd_widgets = [urwid.Text(hub.motd)]
|
||||
for w in motd_widgets:
|
||||
lines.append(urwid.Padding(w, left=2))
|
||||
|
||||
if hub.rooms:
|
||||
lines.append(urwid.Divider(g["divider1"]))
|
||||
lines.append(urwid.Text(" Joined rooms:"))
|
||||
@@ -1314,28 +1580,26 @@ class ChannelsDisplay():
|
||||
urwid.connect_signal(entry, "click", self._select_room, (hub, name))
|
||||
lines.append(urwid.AttrMap(entry, "list_unknown", "list_focus"))
|
||||
|
||||
info = HubInfoArea(urwid.Filler(urwid.Pile(lines), "top"), title=hub.name)
|
||||
body = ScrollBar(Scrollable(urwid.Pile(lines)), thumb_char="┃", trough_char=" ")
|
||||
info = HubInfoArea(urwid.AttrMap(body, "scrollbar"), title=hub.name)
|
||||
info.delegate = self
|
||||
self.current_room_widget = None
|
||||
options = self.columns_widget.options(urwid.WEIGHT, 1)
|
||||
self.columns_widget.contents[1] = (info, options)
|
||||
self._set_right_widget(info)
|
||||
self.shortcuts_display = self.list_shortcuts
|
||||
self.app.ui.main_display.update_active_shortcuts()
|
||||
|
||||
def show_placeholder(self):
|
||||
self.current_room_widget = None
|
||||
self.selected_key = None
|
||||
options = self.columns_widget.options(urwid.WEIGHT, 1)
|
||||
self.columns_widget.contents[1] = (self.placeholder, options)
|
||||
self._set_right_widget(self.placeholder)
|
||||
self.shortcuts_display = self.list_shortcuts
|
||||
self.app.ui.main_display.update_active_shortcuts()
|
||||
|
||||
def _show_room(self, hub, room):
|
||||
widget = RoomWidget(self, hub, room)
|
||||
self.current_room_widget = widget
|
||||
options = self.columns_widget.options(urwid.WEIGHT, 1)
|
||||
self.columns_widget.contents[1] = (widget, options)
|
||||
self.columns_widget.focus_position = 1
|
||||
self._set_right_widget(widget)
|
||||
self.columns_widget.focus_position = len(self.columns_widget.contents)-1
|
||||
self.shortcuts_display = self.room_shortcuts
|
||||
self.app.ui.main_display.update_active_shortcuts()
|
||||
|
||||
@@ -1608,6 +1872,87 @@ class ChannelsDisplay():
|
||||
dialog.delegate = self
|
||||
self._show_dialog_overlay(dialog)
|
||||
|
||||
def show_user_info(self, sender, payload):
|
||||
try:
|
||||
hub, peer_hash, display_name = payload
|
||||
except Exception:
|
||||
return
|
||||
if not isinstance(peer_hash, (bytes, bytearray)):
|
||||
return
|
||||
|
||||
peer_hash = bytes(peer_hash)
|
||||
identity_hex = RNS.hexrep(peer_hash, delimit=False)
|
||||
own_hash = self.app.identity.hash if self.app.identity is not None else None
|
||||
is_self = (own_hash is not None and peer_hash == own_hash)
|
||||
|
||||
lxmf_hex = None
|
||||
try:
|
||||
peer_identity = RNS.Identity.recall(peer_hash, from_identity_hash=True)
|
||||
if peer_identity is not None:
|
||||
lxmf_dest = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", peer_identity)
|
||||
lxmf_hex = RNS.hexrep(lxmf_dest, delimit=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_close(_b):
|
||||
self.close_dialog()
|
||||
|
||||
def on_open(_b):
|
||||
self.close_dialog()
|
||||
if lxmf_hex is None:
|
||||
return
|
||||
try:
|
||||
_ChatLinkDelegate(self, hub)._open_lxmf(lxmf_hex)
|
||||
except Exception as e:
|
||||
RNS.log("Could not open conversation: "+str(e), RNS.LOG_ERROR)
|
||||
|
||||
safe_name = ""
|
||||
try:
|
||||
if display_name:
|
||||
if self.app.config["textui"]["sanitize_names"]:
|
||||
safe_name = sanitize_name(str(display_name)) or ""
|
||||
else:
|
||||
safe_name = strip_modifiers(str(display_name)) or ""
|
||||
except Exception:
|
||||
safe_name = ""
|
||||
|
||||
lines = [
|
||||
urwid.Text(""),
|
||||
urwid.Text(" Nick : "+safe_name),
|
||||
urwid.Text(" Identity : "+identity_hex),
|
||||
]
|
||||
if lxmf_hex:
|
||||
lines.append(urwid.Text(" LXMF : "+lxmf_hex))
|
||||
|
||||
if is_self:
|
||||
lines.append(urwid.Text(""))
|
||||
lines.append(urwid.Text(" (This is you)", align=urwid.CENTER))
|
||||
lines.append(urwid.Text(""))
|
||||
lines.append(urwid.Columns([
|
||||
(urwid.WEIGHT, 1, urwid.Button("Close", on_press=on_close)),
|
||||
]))
|
||||
else:
|
||||
if lxmf_hex is None:
|
||||
lines.append(urwid.Text(""))
|
||||
lines.append(urwid.Text(" Identity not in local cache;", align=urwid.CENTER))
|
||||
lines.append(urwid.Text(" conversation can't be opened until", align=urwid.CENTER))
|
||||
lines.append(urwid.Text(" the peer announces.", align=urwid.CENTER))
|
||||
lines.append(urwid.Text(""))
|
||||
lines.append(urwid.Columns([
|
||||
(urwid.WEIGHT, 1, urwid.Button("Close", on_press=on_close)),
|
||||
]))
|
||||
else:
|
||||
lines.append(urwid.Text(""))
|
||||
lines.append(urwid.Columns([
|
||||
(urwid.WEIGHT, 0.55, urwid.Button("Open Conversation", on_press=on_open)),
|
||||
(urwid.WEIGHT, 0.05, urwid.Text("")),
|
||||
(urwid.WEIGHT, 0.40, urwid.Button("Close", on_press=on_close)),
|
||||
]))
|
||||
|
||||
dialog = ChannelsDialogLineBox(urwid.Pile(lines), title="User Info")
|
||||
dialog.delegate = self
|
||||
self._show_dialog_overlay(dialog)
|
||||
|
||||
def _show_dialog_overlay(self, dialog):
|
||||
self.dialog_open = True
|
||||
overlay = urwid.Overlay(
|
||||
@@ -1659,6 +2004,14 @@ class ChannelsDisplay():
|
||||
self.current_room_widget._refresh_users_pane()
|
||||
except Exception:
|
||||
pass
|
||||
elif (self.selected_key
|
||||
and self.selected_key[0] == "hub"
|
||||
and self.selected_key[1] == hub.hub_hash
|
||||
and self.selected_key[2] == hub.dest_name):
|
||||
try:
|
||||
self._show_hub_info(hub)
|
||||
except Exception:
|
||||
pass
|
||||
self._wake(action)
|
||||
|
||||
def _on_rrc_message(self, hub, msg):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+253
-4
@@ -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 <room>`* 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 <name>`* set your display name on this hub
|
||||
- `*/topic <room> [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.
|
||||
|
||||
@@ -1085,6 +1251,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.
|
||||
``
|
||||
'''
|
||||
|
||||
|
||||
@@ -1143,6 +1326,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.
|
||||
@@ -1354,6 +1553,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:
|
||||
@@ -1576,6 +1824,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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user