mirror of
https://github.com/markqvist/NomadNet.git
synced 2026-06-07 08:52:32 +00:00
3070 lines
121 KiB
Python
3070 lines
121 KiB
Python
import RNS
|
|
import RNS.vendor.umsgpack as msgpack
|
|
import collections
|
|
import os
|
|
import shutil
|
|
import time
|
|
import nomadnet
|
|
import LXMF
|
|
|
|
import urwid
|
|
|
|
from datetime import datetime, timedelta
|
|
from nomadnet.Directory import DirectoryEntry
|
|
from LXMF import pn_announce_data_is_valid, PN_META_NAME
|
|
from nomadnet.Conversation import ConversationMessage
|
|
|
|
from nomadnet.util import strip_modifiers
|
|
from nomadnet.util import sanitize_name
|
|
|
|
from RNS.Utilities.rngit.util import MarkdownToMicron
|
|
from RNS.Utilities.rngit.highlight import SyntaxHighlighter
|
|
from .MicronParser import markup_to_attrmaps
|
|
from .Helpers import ClickableIcon, osc52_copy
|
|
from nomadnet.util import strip_modifiers, strip_micron, strip_escaped_micron, unescape_micron, strip_non_formatting_tags
|
|
from nomadnet.ui import THEME_DARK, THEME_LIGHT
|
|
|
|
def relative_time(timestamp):
|
|
now = time.time()
|
|
delta = now - timestamp
|
|
if delta < 0:
|
|
return "just now"
|
|
elif delta < 60:
|
|
return "just now"
|
|
elif delta < 3600:
|
|
m = int(delta / 60)
|
|
return str(m)+"m ago"
|
|
elif delta < 86400:
|
|
h = int(delta / 3600)
|
|
return str(h)+"h ago"
|
|
elif delta < 172800:
|
|
return "yesterday"
|
|
elif delta < 604800:
|
|
d = int(delta / 86400)
|
|
return str(d)+"d ago"
|
|
elif delta < 2592000:
|
|
w = int(delta / 604800)
|
|
return str(w)+"w ago"
|
|
else:
|
|
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d")
|
|
|
|
|
|
def _format_size(size):
|
|
if size < 1024:
|
|
return str(size)+" B"
|
|
elif size < 1048576:
|
|
return str(round(size/1024, 1))+" KB"
|
|
else:
|
|
return str(round(size/1048576, 1))+" MB"
|
|
|
|
|
|
from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox
|
|
|
|
class ConversationListDisplayShortcuts():
|
|
def __init__(self, app):
|
|
self.app = app
|
|
|
|
self.widget = urwid.AttrMap(urwid.Text("[C-e] Peer Info [C-x] Delete [C-r] Sync [C-n] New [C-u] Ingest URI [C-o] Sort [C-p] My LXMF [C-g] Fullscreen"), "shortcutbar")
|
|
|
|
class ConversationDisplayShortcuts():
|
|
def __init__(self, app):
|
|
self.app = app
|
|
|
|
self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-p] Paper Msg [C-t] Title [C-a] Attach [C-s] Save [C-k] Clear [C-w] Close [C-u] Purge [C-x] Clear History [C-o] Sort"), "shortcutbar")
|
|
|
|
class TabButton(urwid.Button):
|
|
button_left = urwid.Text("[")
|
|
button_right = urwid.Text("]")
|
|
|
|
|
|
class ConversationsArea(urwid.LineBox):
|
|
def keypress(self, size, key):
|
|
if key == "ctrl e":
|
|
self.delegate.edit_selected_in_directory()
|
|
elif key == "ctrl x":
|
|
self.delegate.delete_selected_conversation()
|
|
elif key == "ctrl n":
|
|
self.delegate.new_conversation()
|
|
elif key == "ctrl u":
|
|
self.delegate.ingest_lxm_uri()
|
|
elif key == "ctrl r":
|
|
self.delegate.sync_conversations()
|
|
elif key == "ctrl g":
|
|
self.delegate.toggle_fullscreen()
|
|
elif key == "ctrl o":
|
|
self.delegate.toggle_list_sort()
|
|
elif key == "ctrl p":
|
|
self.delegate.show_my_qr()
|
|
elif key == "tab":
|
|
self.delegate.app.ui.main_display.frame.focus_position = "header"
|
|
elif key == "up":
|
|
if self.delegate.ilb.body_is_empty():
|
|
self.delegate.app.ui.main_display.frame.focus_position = "header"
|
|
return None
|
|
result = super(ConversationsArea, self).keypress(size, key)
|
|
if result == "up":
|
|
self.delegate.app.ui.main_display.frame.focus_position = "header"
|
|
return None
|
|
return result
|
|
else:
|
|
return super(ConversationsArea, self).keypress(size, key)
|
|
|
|
class PropNodePicker(urwid.WidgetWrap):
|
|
def __init__(self, options, current_hash, on_change):
|
|
self._options = list(options)
|
|
self._current = current_hash
|
|
self._on_change = on_change
|
|
self._pile = urwid.Pile([])
|
|
super().__init__(self._pile)
|
|
self._show_collapsed()
|
|
|
|
def _label_for(self, h):
|
|
for ph, label in self._options:
|
|
if ph == h:
|
|
return label
|
|
if h is not None:
|
|
return "<"+RNS.hexrep(h, delimit=False)+">"
|
|
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":
|
|
if hasattr(self.delegate, "update_conversation_list"):
|
|
self.delegate.update_conversation_list()
|
|
elif hasattr(self.delegate, "dialog_active"):
|
|
self.delegate.dialog_active = False
|
|
self.delegate.conversation_changed(None)
|
|
else:
|
|
return super(DialogLineBox, self).keypress(size, key)
|
|
|
|
class ConversationsDisplay():
|
|
list_width = 0.33
|
|
given_list_width = 52
|
|
cached_conversation_widgets = {}
|
|
|
|
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(
|
|
[
|
|
# (urwid.WEIGHT, ConversationsDisplay.list_width, self.listbox),
|
|
# (urwid.WEIGHT, 1-ConversationsDisplay.list_width, self.make_conversation_widget(None))
|
|
(ConversationsDisplay.given_list_width, self.listbox),
|
|
(urwid.WEIGHT, 1, self.make_conversation_widget(None))
|
|
],
|
|
dividechars=0, focus_column=0, box_columns=[0]
|
|
)
|
|
|
|
self.list_shortcuts = ConversationListDisplayShortcuts(self.app)
|
|
self.editor_shortcuts = ConversationDisplayShortcuts(self.app)
|
|
|
|
self.shortcuts_display = self.list_shortcuts
|
|
self.widget = urwid.WidgetPlaceholder(self.columns_widget)
|
|
|
|
self._pending_actions = collections.deque()
|
|
self._wake_fd = None
|
|
try:
|
|
self._wake_fd = self.app.ui.loop.watch_pipe(self._process_pending)
|
|
except Exception:
|
|
pass
|
|
|
|
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:
|
|
action = self._pending_actions.popleft()
|
|
except IndexError:
|
|
break
|
|
try:
|
|
action()
|
|
except Exception as e:
|
|
RNS.log("Conversations UI action failed: "+str(e), RNS.LOG_ERROR)
|
|
return True
|
|
|
|
def _wake(self, action):
|
|
self._pending_actions.append(action)
|
|
if self._wake_fd is not None:
|
|
try:
|
|
os.write(self._wake_fd, b".")
|
|
return
|
|
except Exception:
|
|
pass
|
|
try:
|
|
self.app.ui.loop.set_alarm_in(0.0, lambda l, d: self._process_pending(None))
|
|
except Exception:
|
|
pass
|
|
|
|
def focus_change_event(self):
|
|
if not self.dialog_open:
|
|
self.update_conversation_list()
|
|
|
|
def toggle_list_sort(self):
|
|
if self.list_sort_mode == ConversationsDisplay.SORT_RECENT:
|
|
self.list_sort_mode = ConversationsDisplay.SORT_NAME
|
|
else:
|
|
self.list_sort_mode = ConversationsDisplay.SORT_RECENT
|
|
self.update_conversation_list()
|
|
|
|
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
|
|
|
|
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.ilb = IndicativeListBox(
|
|
[urwid.Text("")],
|
|
on_selection_change=self.conversation_list_selection,
|
|
initialization_is_selection_change=False,
|
|
highlight_offFocus="list_off_focus",
|
|
)
|
|
|
|
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()
|
|
if item == None:
|
|
return
|
|
source_hash = item.source_hash
|
|
|
|
def dismiss_dialog(sender):
|
|
self.dialog_open = False
|
|
self.update_conversation_list()
|
|
|
|
def confirmed(sender):
|
|
self.dialog_open = False
|
|
self.delete_conversation(source_hash)
|
|
nomadnet.Conversation.delete_conversation(source_hash, self.app)
|
|
self.update_conversation_list()
|
|
|
|
dialog = DialogLineBox(
|
|
urwid.Pile([
|
|
urwid.Text(
|
|
"Delete conversation with\n"+self.app.directory.simplest_display_str(bytes.fromhex(source_hash))+"\n",
|
|
align=urwid.CENTER,
|
|
),
|
|
urwid.Columns([
|
|
(urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=confirmed)),
|
|
(urwid.WEIGHT, 0.1, urwid.Text("")),
|
|
(urwid.WEIGHT, 0.45, urwid.Button("No", on_press=dismiss_dialog)),
|
|
])
|
|
]), title="?"
|
|
)
|
|
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,
|
|
)
|
|
|
|
# options = self.columns_widget.options(urwid.WEIGHT, ConversationsDisplay.list_width)
|
|
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.dialog_open = False
|
|
self.update_conversation_list()
|
|
|
|
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.dialog_open = False
|
|
self.update_conversation_list()
|
|
|
|
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
|
|
item = self.ilb.get_selected_item()
|
|
if item == None:
|
|
self.dialog_open = False
|
|
return
|
|
source_hash_text = getattr(item, "source_hash", None)
|
|
if source_hash_text is None:
|
|
blocked = getattr(item, "blocked_dest_hash", None)
|
|
if isinstance(blocked, (bytes, bytearray)):
|
|
source_hash_text = RNS.hexrep(blocked, delimit=False)
|
|
if source_hash_text is None:
|
|
self.dialog_open = False
|
|
return
|
|
display_name = getattr(item, "display_name", None)
|
|
if display_name is None:
|
|
try:
|
|
display_name = self.app.directory.display_name(bytes.fromhex(source_hash_text))
|
|
except Exception:
|
|
display_name = None
|
|
if display_name is None:
|
|
display_name = ""
|
|
|
|
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
|
|
|
|
untrusted_selected = False
|
|
unknown_selected = True
|
|
trusted_selected = False
|
|
|
|
direct_selected = True
|
|
propagated_selected = False
|
|
|
|
pinned_initial = False
|
|
notes_initial = ""
|
|
|
|
try:
|
|
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
|
|
unknown_selected = False
|
|
trusted_selected = False
|
|
elif trust_level == DirectoryEntry.UNKNOWN:
|
|
untrusted_selected = False
|
|
unknown_selected = True
|
|
trusted_selected = False
|
|
elif trust_level == DirectoryEntry.TRUSTED:
|
|
untrusted_selected = False
|
|
unknown_selected = False
|
|
trusted_selected = True
|
|
|
|
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)
|
|
r_trusted = urwid.RadioButton(trust_button_group, "Trusted", state=trusted_selected)
|
|
|
|
method_button_group = []
|
|
r_direct = urwid.RadioButton(method_button_group, "Deliver directly", state=direct_selected)
|
|
r_propagated = urwid.RadioButton(method_button_group, "Use propagation nodes", state=propagated_selected)
|
|
|
|
def dismiss_dialog(sender):
|
|
self.dialog_open = False
|
|
self.update_conversation_list()
|
|
|
|
def confirmed(sender):
|
|
try:
|
|
display_name = e_name.get_edit_text()
|
|
source_hash = bytes.fromhex(e_id.get_edit_text())
|
|
trust_level = DirectoryEntry.UNTRUSTED
|
|
if r_unknown.state == True:
|
|
trust_level = DirectoryEntry.UNKNOWN
|
|
elif r_trusted.state == True:
|
|
trust_level = DirectoryEntry.TRUSTED
|
|
|
|
delivery = DirectoryEntry.DIRECT
|
|
if r_propagated.state == True:
|
|
delivery = DirectoryEntry.PROPAGATED
|
|
|
|
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.dialog_open = False
|
|
self.update_conversation_list()
|
|
self.app.ui.main_display.sub_displays.network_display.directory_change_callback()
|
|
except Exception as e:
|
|
RNS.log("Could not save directory entry. The contained exception was: "+str(e), RNS.LOG_VERBOSE)
|
|
if not dialog_pile.error_display:
|
|
dialog_pile.error_display = True
|
|
options = dialog_pile.options(height_type=urwid.PACK)
|
|
dialog_pile.contents.append((urwid.Text(""), options))
|
|
dialog_pile.contents.append((
|
|
urwid.Text(("error_text", "Could not save entry. Check your input."), align=urwid.CENTER),
|
|
options,)
|
|
)
|
|
|
|
source_is_known = self.app.directory.is_known(bytes.fromhex(source_hash_text))
|
|
if source_is_known:
|
|
known_section = urwid.Divider(g["divider1"])
|
|
else:
|
|
def query_action(sender, user_data):
|
|
self.close_conversation_by_hash(user_data)
|
|
nomadnet.Conversation.query_for_peer(user_data)
|
|
options = dialog_pile.options(height_type=urwid.PACK)
|
|
dialog_pile.contents = [
|
|
(urwid.Text("Query sent"), options),
|
|
(urwid.Button("OK", on_press=dismiss_dialog), options)
|
|
]
|
|
query_button = urwid.Button("Query network for keys", on_press=query_action, user_data=source_hash_text)
|
|
known_section = urwid.Pile([
|
|
urwid.Divider(g["divider1"]),
|
|
urwid.Text(g["info"]+"\n", align=urwid.CENTER),
|
|
urwid.Text(
|
|
"The identity of this peer is not known, and you cannot currently send messages to it. "
|
|
"You can query the network to obtain the identity.\n",
|
|
align=urwid.CENTER,
|
|
),
|
|
query_button,
|
|
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,
|
|
r_trusted,
|
|
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("")),
|
|
(urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=dismiss_dialog)),
|
|
])
|
|
])
|
|
dialog_pile.error_display = False
|
|
|
|
dialog = DialogLineBox(dialog_pile, title="Peer Info")
|
|
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,
|
|
)
|
|
|
|
# options = self.columns_widget.options(urwid.WEIGHT, ConversationsDisplay.list_width)
|
|
options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width)
|
|
self.columns_widget.contents[0] = (overlay, options)
|
|
|
|
def new_conversation(self):
|
|
self.dialog_open = True
|
|
source_hash = ""
|
|
display_name = ""
|
|
|
|
e_id = urwid.Edit(caption="Addr : ",edit_text=source_hash)
|
|
e_name = urwid.Edit(caption="Name : ",edit_text=display_name)
|
|
|
|
trust_button_group = []
|
|
r_untrusted = urwid.RadioButton(trust_button_group, "Untrusted")
|
|
r_unknown = urwid.RadioButton(trust_button_group, "Unknown", state=True)
|
|
r_trusted = urwid.RadioButton(trust_button_group, "Trusted")
|
|
|
|
def dismiss_dialog(sender):
|
|
self.dialog_open = False
|
|
self.update_conversation_list()
|
|
|
|
def confirmed(sender):
|
|
try:
|
|
existing_conversations = nomadnet.Conversation.conversation_list(self.app)
|
|
|
|
display_name = e_name.get_edit_text()
|
|
source_hash_text = e_id.get_edit_text().strip()
|
|
source_hash = bytes.fromhex(source_hash_text)
|
|
trust_level = DirectoryEntry.UNTRUSTED
|
|
if r_unknown.state == True:
|
|
trust_level = DirectoryEntry.UNKNOWN
|
|
elif r_trusted.state == True:
|
|
trust_level = DirectoryEntry.TRUSTED
|
|
|
|
if not source_hash in [c[0] for c in existing_conversations]:
|
|
entry = DirectoryEntry(source_hash, display_name, trust_level)
|
|
self.app.directory.remember(entry)
|
|
|
|
new_conversation = nomadnet.Conversation(source_hash_text, nomadnet.NomadNetworkApp.get_shared_instance(), initiator=True)
|
|
|
|
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
|
|
self.update_conversation_list()
|
|
|
|
except Exception as e:
|
|
RNS.log("Could not start conversation. The contained exception was: "+str(e), RNS.LOG_VERBOSE)
|
|
if not dialog_pile.error_display:
|
|
dialog_pile.error_display = True
|
|
options = dialog_pile.options(height_type=urwid.PACK)
|
|
dialog_pile.contents.append((urwid.Text(""), options))
|
|
dialog_pile.contents.append((
|
|
urwid.Text(
|
|
("error_text", "Could not start conversation. Check your input."),
|
|
align=urwid.CENTER,
|
|
),
|
|
options,
|
|
))
|
|
|
|
dialog_pile = urwid.Pile([
|
|
e_id,
|
|
e_name,
|
|
urwid.Text(""),
|
|
r_untrusted,
|
|
r_unknown,
|
|
r_trusted,
|
|
urwid.Text(""),
|
|
urwid.Columns([
|
|
(urwid.WEIGHT, 0.45, urwid.Button("Create", on_press=confirmed)),
|
|
(urwid.WEIGHT, 0.1, urwid.Text("")),
|
|
(urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=dismiss_dialog)),
|
|
])
|
|
])
|
|
dialog_pile.error_display = False
|
|
|
|
dialog = DialogLineBox(dialog_pile, title="New Conversation")
|
|
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,
|
|
)
|
|
|
|
# options = self.columns_widget.options(urwid.WEIGHT, ConversationsDisplay.list_width)
|
|
options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width)
|
|
self.columns_widget.contents[0] = (overlay, options)
|
|
|
|
def ingest_lxm_uri(self):
|
|
self.dialog_open = True
|
|
lxm_uri = ""
|
|
e_uri = urwid.Edit(caption="URI : ",edit_text=lxm_uri)
|
|
|
|
def dismiss_dialog(sender):
|
|
self.dialog_open = False
|
|
self.update_conversation_list()
|
|
|
|
def confirmed(sender):
|
|
try:
|
|
local_delivery_signal = "local_delivery_occurred"
|
|
duplicate_signal = "duplicate_lxm"
|
|
lxm_uri = e_uri.get_edit_text().strip()
|
|
|
|
ingest_result = self.app.message_router.ingest_lxm_uri(
|
|
lxm_uri,
|
|
signal_local_delivery=local_delivery_signal,
|
|
signal_duplicate=duplicate_signal
|
|
)
|
|
|
|
if ingest_result == False:
|
|
raise ValueError("The URI contained no decodable messages")
|
|
|
|
elif ingest_result == local_delivery_signal:
|
|
rdialog_pile = urwid.Pile([
|
|
urwid.Text("Message was decoded, decrypted successfully, and added to your conversation list."),
|
|
urwid.Text(""),
|
|
urwid.Columns([
|
|
(urwid.WEIGHT, 0.6, urwid.Text("")),
|
|
(urwid.WEIGHT, 0.4, urwid.Button("OK", on_press=dismiss_dialog)),
|
|
])
|
|
])
|
|
rdialog_pile.error_display = False
|
|
|
|
rdialog = DialogLineBox(rdialog_pile, title="Ingest message URI")
|
|
rdialog.delegate = self
|
|
bottom = self.listbox
|
|
|
|
roverlay = urwid.Overlay(
|
|
rdialog,
|
|
bottom,
|
|
align=urwid.CENTER,
|
|
width=urwid.RELATIVE_100,
|
|
valign=urwid.MIDDLE,
|
|
height=urwid.PACK,
|
|
left=2,
|
|
right=2,
|
|
)
|
|
|
|
options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width)
|
|
self.columns_widget.contents[0] = (roverlay, options)
|
|
|
|
elif ingest_result == duplicate_signal:
|
|
rdialog_pile = urwid.Pile([
|
|
urwid.Text("The decoded message has already been processed by the LXMF Router, and will not be ingested again."),
|
|
urwid.Text(""),
|
|
urwid.Columns([
|
|
(urwid.WEIGHT, 0.6, urwid.Text("")),
|
|
(urwid.WEIGHT, 0.4, urwid.Button("OK", on_press=dismiss_dialog)),
|
|
])
|
|
])
|
|
rdialog_pile.error_display = False
|
|
|
|
rdialog = DialogLineBox(rdialog_pile, title="Ingest message URI")
|
|
rdialog.delegate = self
|
|
bottom = self.listbox
|
|
|
|
roverlay = urwid.Overlay(
|
|
rdialog,
|
|
bottom,
|
|
align=urwid.CENTER,
|
|
width=urwid.RELATIVE_100,
|
|
valign=urwid.MIDDLE,
|
|
height=urwid.PACK,
|
|
left=2,
|
|
right=2,
|
|
)
|
|
|
|
options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width)
|
|
self.columns_widget.contents[0] = (roverlay, options)
|
|
|
|
else:
|
|
if self.app.enable_node:
|
|
propagation_text = "The decoded message was not addressed to this LXMF address, but has been added to the propagation node queues, and will be distributed on the propagation network."
|
|
else:
|
|
propagation_text = "The decoded message was not addressed to this LXMF address, and has been discarded."
|
|
|
|
rdialog_pile = urwid.Pile([
|
|
urwid.Text(propagation_text),
|
|
urwid.Text(""),
|
|
urwid.Columns([
|
|
(urwid.WEIGHT, 0.6, urwid.Text("")),
|
|
(urwid.WEIGHT, 0.4, urwid.Button("OK", on_press=dismiss_dialog)),
|
|
])
|
|
])
|
|
rdialog_pile.error_display = False
|
|
|
|
rdialog = DialogLineBox(rdialog_pile, title="Ingest message URI")
|
|
rdialog.delegate = self
|
|
bottom = self.listbox
|
|
|
|
roverlay = urwid.Overlay(
|
|
rdialog,
|
|
bottom,
|
|
align=urwid.CENTER,
|
|
width=urwid.RELATIVE_100,
|
|
valign=urwid.MIDDLE,
|
|
height=urwid.PACK,
|
|
left=2,
|
|
right=2,
|
|
)
|
|
|
|
options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width)
|
|
self.columns_widget.contents[0] = (roverlay, options)
|
|
|
|
except Exception as e:
|
|
RNS.log("Could not ingest LXM URI. The contained exception was: "+str(e), RNS.LOG_VERBOSE)
|
|
if not dialog_pile.error_display:
|
|
dialog_pile.error_display = True
|
|
options = dialog_pile.options(height_type=urwid.PACK)
|
|
dialog_pile.contents.append((urwid.Text(""), options))
|
|
dialog_pile.contents.append((urwid.Text(("error_text", "Could ingest LXM from URI data. Check your input."), align=urwid.CENTER), options))
|
|
|
|
dialog_pile = urwid.Pile([
|
|
e_uri,
|
|
urwid.Text(""),
|
|
urwid.Columns([
|
|
(urwid.WEIGHT, 0.45, urwid.Button("Ingest", on_press=confirmed)),
|
|
(urwid.WEIGHT, 0.1, urwid.Text("")),
|
|
(urwid.WEIGHT, 0.45, urwid.Button("Back", on_press=dismiss_dialog)),
|
|
])
|
|
])
|
|
dialog_pile.error_display = False
|
|
|
|
dialog = DialogLineBox(dialog_pile, title="Ingest message URI")
|
|
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,
|
|
)
|
|
|
|
options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width)
|
|
self.columns_widget.contents[0] = (overlay, options)
|
|
|
|
def delete_conversation(self, source_hash):
|
|
if source_hash in ConversationsDisplay.cached_conversation_widgets:
|
|
conversation = ConversationsDisplay.cached_conversation_widgets[source_hash]
|
|
self.close_conversation(conversation)
|
|
|
|
def toggle_fullscreen(self):
|
|
if ConversationsDisplay.given_list_width != 0:
|
|
self.saved_list_width = ConversationsDisplay.given_list_width
|
|
ConversationsDisplay.given_list_width = 0
|
|
else:
|
|
ConversationsDisplay.given_list_width = self.saved_list_width
|
|
|
|
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
|
|
self.update_conversation_list()
|
|
if self.app.message_router.propagation_transfer_state >= LXMF.LXMRouter.PR_COMPLETE:
|
|
self.app.cancel_lxmf_sync()
|
|
|
|
max_messages_group = []
|
|
r_mall = urwid.RadioButton(max_messages_group, "Download all", state=True)
|
|
r_mlim = urwid.RadioButton(max_messages_group, "Limit to", state=False)
|
|
ie_lim = urwid.IntEdit("", 5)
|
|
rbs = urwid.GridFlow([r_mlim, ie_lim], 12, 1, 0, align=urwid.LEFT)
|
|
|
|
def sync_now(sender):
|
|
limit = None
|
|
if r_mlim.get_state():
|
|
limit = ie_lim.value()
|
|
self.app.request_lxmf_sync(limit)
|
|
self.update_sync_dialog()
|
|
|
|
def cancel_sync(sender):
|
|
self.app.cancel_lxmf_sync()
|
|
self.update_sync_dialog()
|
|
|
|
cancel_button = urwid.Button("Close", on_press=dismiss_dialog)
|
|
sync_progress = SyncProgressBar("progress_empty" , "progress_full", current=self.app.get_sync_progress(), done=1.0, satt=None)
|
|
|
|
real_sync_button = urwid.Button("Sync Now", on_press=sync_now)
|
|
hidden_sync_button = urwid.Button("Cancel Sync", on_press=cancel_sync)
|
|
|
|
if self.app.get_sync_status() == "Idle" or self.app.message_router.propagation_transfer_state >= LXMF.LXMRouter.PR_COMPLETE:
|
|
sync_button = real_sync_button
|
|
else:
|
|
sync_button = hidden_sync_button
|
|
|
|
button_columns = urwid.Columns([
|
|
(urwid.WEIGHT, 0.45, sync_button),
|
|
(urwid.WEIGHT, 0.1, urwid.Text("")),
|
|
(urwid.WEIGHT, 0.45, cancel_button),
|
|
])
|
|
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)
|
|
|
|
def show_set_pn_dialog(_sender):
|
|
current_pn = self.app.get_user_selected_propagation_node()
|
|
current_str = RNS.hexrep(current_pn, delimit=False) if current_pn is not None else ""
|
|
pn_edit = urwid.Edit(caption="Hash : ", edit_text=current_str)
|
|
status_text = urwid.Text("", align=urwid.CENTER)
|
|
|
|
def reopen_sync(_b=None):
|
|
self.sync_conversations()
|
|
|
|
def save_pn(_b):
|
|
text = pn_edit.get_edit_text().strip().replace(":", "").replace(" ", "")
|
|
expected_len = RNS.Reticulum.TRUNCATED_HASHLENGTH // 8
|
|
if text == "":
|
|
self.app.set_user_selected_propagation_node(None)
|
|
else:
|
|
try:
|
|
node_hash = bytes.fromhex(text)
|
|
except ValueError:
|
|
status_text.set_text("Invalid hex")
|
|
return
|
|
if len(node_hash) != expected_len:
|
|
status_text.set_text("Must be "+str(expected_len)+" bytes ("+str(expected_len*2)+" hex chars)")
|
|
return
|
|
self.app.set_user_selected_propagation_node(node_hash)
|
|
reopen_sync()
|
|
|
|
def clear_pn(_b):
|
|
pn_edit.set_edit_text("")
|
|
self.app.set_user_selected_propagation_node(None)
|
|
reopen_sync()
|
|
|
|
inner = DialogLineBox(
|
|
urwid.Pile([
|
|
urwid.Text("Enter an LXMF propagation\ndestination hash as hex.", align=urwid.CENTER),
|
|
urwid.Divider(),
|
|
pn_edit,
|
|
urwid.Divider(),
|
|
status_text,
|
|
urwid.Columns([
|
|
(urwid.WEIGHT, 0.3, urwid.Button("Save", on_press=save_pn)),
|
|
(urwid.WEIGHT, 0.05, urwid.Text("")),
|
|
(urwid.WEIGHT, 0.3, urwid.Button("Clear", on_press=clear_pn)),
|
|
(urwid.WEIGHT, 0.05, urwid.Text("")),
|
|
(urwid.WEIGHT, 0.3, urwid.Button("Close", on_press=reopen_sync)),
|
|
])
|
|
]), title="Set Propagation Node",
|
|
)
|
|
inner.delegate = self
|
|
self.sync_dialog = None
|
|
overlay = urwid.Overlay(
|
|
inner, self.listbox,
|
|
align=urwid.CENTER, width=urwid.RELATIVE_100,
|
|
valign=urwid.MIDDLE, height=urwid.PACK,
|
|
left=2, right=2,
|
|
)
|
|
options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width)
|
|
self.columns_widget.contents[0] = (overlay, options)
|
|
|
|
set_pn_button = urwid.Button("Custom Node...", on_press=show_set_pn_dialog)
|
|
|
|
if pn_options:
|
|
node_picker = PropNodePicker(pn_options, selected_target, on_pn_picked)
|
|
node_selector = urwid.Pile([
|
|
urwid.Text("Propagation node:"),
|
|
node_picker,
|
|
set_pn_button,
|
|
])
|
|
else:
|
|
node_selector = None
|
|
|
|
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(current_default)
|
|
|
|
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:
|
|
header_str = " (no default)"
|
|
|
|
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("" )),
|
|
(urwid.WEIGHT, 0.1, urwid.Text("")),
|
|
(urwid.WEIGHT, 0.45, cancel_button),
|
|
])
|
|
dialog = DialogLineBox(
|
|
urwid.Pile([
|
|
urwid.Text(""),
|
|
urwid.Text("No trusted nodes found, cannot sync!\n", align=urwid.CENTER),
|
|
urwid.Text(
|
|
"To synchronise messages from the network, "
|
|
"one or more nodes must be marked as trusted in the Known Nodes list, "
|
|
"or a node must manually be selected as the default propagation node. "
|
|
"Nomad Network will then automatically sync from the nearest trusted node, "
|
|
"or the manually selected one.",
|
|
align=urwid.LEFT,
|
|
),
|
|
urwid.Text(""),
|
|
button_columns
|
|
]), title="Message Sync"
|
|
)
|
|
|
|
dialog.delegate = self
|
|
dialog.sync_progress = sync_progress
|
|
dialog.cancel_button = cancel_button
|
|
dialog.real_sync_button = real_sync_button
|
|
dialog.hidden_sync_button = hidden_sync_button
|
|
dialog.bc = button_columns
|
|
|
|
self.sync_dialog = dialog
|
|
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,
|
|
)
|
|
|
|
# options = self.columns_widget.options(urwid.WEIGHT, ConversationsDisplay.list_width)
|
|
options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width)
|
|
self.columns_widget.contents[0] = (overlay, options)
|
|
|
|
def update_sync_dialog(self, loop = None, sender = None):
|
|
if self.dialog_open and self.sync_dialog != None:
|
|
self.sync_dialog.sync_progress.set_completion(self.app.get_sync_progress())
|
|
|
|
if self.app.get_sync_status() == "Idle" or self.app.message_router.propagation_transfer_state >= LXMF.LXMRouter.PR_COMPLETE:
|
|
self.sync_dialog.bc.contents[0] = (self.sync_dialog.real_sync_button, self.sync_dialog.bc.options(urwid.WEIGHT, 0.45))
|
|
else:
|
|
self.sync_dialog.bc.contents[0] = (self.sync_dialog.hidden_sync_button, self.sync_dialog.bc.options(urwid.WEIGHT, 0.45))
|
|
|
|
self.app.ui.loop.set_alarm_in(0.2, self.update_sync_dialog)
|
|
|
|
|
|
def conversation_list_selection(self, arg1, arg2):
|
|
pass
|
|
|
|
def update_conversation_list(self):
|
|
selected_hash = None
|
|
selected_item = self.ilb.get_selected_item()
|
|
if selected_item is not None:
|
|
if hasattr(selected_item, "source_hash"):
|
|
selected_hash = selected_item.source_hash
|
|
|
|
self.update_listbox()
|
|
options = self.columns_widget.options(urwid.GIVEN, ConversationsDisplay.given_list_width)
|
|
if not self.dialog_open:
|
|
self.columns_widget.contents[0] = (self.listbox, options)
|
|
elif self.sync_dialog is not None:
|
|
bottom = self.listbox
|
|
overlay = urwid.Overlay(
|
|
self.sync_dialog,
|
|
bottom,
|
|
align=urwid.CENTER,
|
|
width=urwid.RELATIVE_100,
|
|
valign=urwid.MIDDLE,
|
|
height=urwid.PACK,
|
|
left=2,
|
|
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):
|
|
if widget.source_hash == selected_hash:
|
|
self.ilb.select_item(idx)
|
|
break
|
|
nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.draw_screen()
|
|
|
|
if self.app.ui.main_display.sub_displays.active_display == self.app.ui.main_display.sub_displays.conversations_display:
|
|
if self.currently_displayed_conversation != None:
|
|
if self.app.conversation_is_unread(self.currently_displayed_conversation):
|
|
self.app.mark_conversation_read(self.currently_displayed_conversation)
|
|
try:
|
|
if os.path.isfile(self.app.conversationpath + "/" + self.currently_displayed_conversation + "/unread"):
|
|
os.unlink(self.app.conversationpath + "/" + self.currently_displayed_conversation + "/unread")
|
|
except Exception as e:
|
|
raise e
|
|
|
|
|
|
|
|
|
|
def display_conversation(self, sender=None, source_hash=None):
|
|
if self.currently_displayed_conversation != None:
|
|
if self.app.conversation_is_unread(self.currently_displayed_conversation):
|
|
self.app.mark_conversation_read(self.currently_displayed_conversation)
|
|
|
|
self.currently_displayed_conversation = source_hash
|
|
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.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.columns_widget.focus_position = 1
|
|
conversation_position = None
|
|
index = 0
|
|
for widget in self.list_widgets:
|
|
if widget.source_hash == source_hash:
|
|
conversation_position = index
|
|
index += 1
|
|
|
|
if conversation_position != None:
|
|
self.ilb.select_item(conversation_position)
|
|
|
|
|
|
def make_conversation_widget(self, source_hash):
|
|
if source_hash in ConversationsDisplay.cached_conversation_widgets:
|
|
conversation_widget = ConversationsDisplay.cached_conversation_widgets[source_hash]
|
|
if source_hash != None:
|
|
conversation_widget.update_message_widgets(replace=True)
|
|
|
|
conversation_widget.check_editor_allowed()
|
|
return conversation_widget
|
|
else:
|
|
widget = ConversationWidget(source_hash, delegate=self)
|
|
ConversationsDisplay.cached_conversation_widgets[source_hash] = widget
|
|
|
|
widget.check_editor_allowed()
|
|
return widget
|
|
|
|
def close_conversation_by_hash(self, conversation_hash):
|
|
if conversation_hash in ConversationsDisplay.cached_conversation_widgets:
|
|
ConversationsDisplay.cached_conversation_widgets.pop(conversation_hash)
|
|
|
|
if self.currently_displayed_conversation == conversation_hash:
|
|
self.display_conversation(sender=None, source_hash=None)
|
|
|
|
def close_conversation(self, conversation):
|
|
if conversation.source_hash in ConversationsDisplay.cached_conversation_widgets:
|
|
ConversationsDisplay.cached_conversation_widgets.pop(conversation.source_hash)
|
|
|
|
if self.currently_displayed_conversation == conversation.source_hash:
|
|
self.display_conversation(sender=None, source_hash=None)
|
|
|
|
|
|
def conversation_list_widget(self, conversation):
|
|
trust_level = conversation[2]
|
|
display_name = conversation[1]
|
|
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
|
|
|
|
if trust_level == DirectoryEntry.UNTRUSTED:
|
|
symbol = g["cross"]
|
|
style = "list_untrusted"
|
|
focus_style = "list_focus_untrusted"
|
|
elif trust_level == DirectoryEntry.UNKNOWN:
|
|
symbol = "?"
|
|
style = "list_unknown"
|
|
focus_style = "list_focus"
|
|
elif trust_level == DirectoryEntry.TRUSTED:
|
|
symbol = g["check"]
|
|
style = "list_normal"
|
|
focus_style = "list_focus"
|
|
elif trust_level == DirectoryEntry.WARNING:
|
|
symbol = g["warning"]
|
|
style = "list_warning"
|
|
focus_style = "list_focus"
|
|
else:
|
|
symbol = g["warning"]
|
|
style = "list_untrusted"
|
|
focus_style = "list_focus_untrusted"
|
|
|
|
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 != "":
|
|
head += " "+display_name
|
|
|
|
if trust_level != DirectoryEntry.TRUSTED:
|
|
head += " <"+source_hash+">"
|
|
|
|
markup = [head]
|
|
if failed and source_hash != self.currently_displayed_conversation:
|
|
badge_text = " "+g["warning"]+" ("+str(failed)+")"
|
|
# markup.append(("msg_notice_caution", badge_text))
|
|
markup.append(badge_text)
|
|
elif unread and source_hash != self.currently_displayed_conversation:
|
|
badge_text = " "+g["unread"]+" ("+str(unread)+")"
|
|
# Good idea with having the badges here colored, but
|
|
# using the bg color for it is a bit much, I think.
|
|
# I set fg color attrmap styles, but that messes up
|
|
# the bg on list focus. If there's a way to handle
|
|
# that, we can re-enable this.
|
|
# markup.append(("msg_notice_unread", badge_text))
|
|
markup.append(badge_text)
|
|
|
|
if trust_level == DirectoryEntry.TRUSTED and unread and source_hash != self.currently_displayed_conversation:
|
|
style = "msg_notice_unread"
|
|
|
|
if last_activity > 0:
|
|
markup.append("\n "+relative_time(last_activity))
|
|
|
|
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
|
|
display_widget.display_name = display_name
|
|
|
|
return display_widget
|
|
|
|
|
|
def shortcuts(self):
|
|
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:
|
|
return self.editor_shortcuts
|
|
else:
|
|
return self.list_shortcuts
|
|
|
|
class ListEntry(urwid.Text):
|
|
_selectable = True
|
|
|
|
signals = ["click"]
|
|
|
|
def keypress(self, size, key):
|
|
"""
|
|
Send 'click' signal on 'activate' command.
|
|
"""
|
|
if self._command_map[key] != urwid.ACTIVATE:
|
|
return key
|
|
|
|
self._emit('click')
|
|
|
|
def mouse_event(self, size, event, button, x, y, focus):
|
|
"""
|
|
Send 'click' signal on button 1 press.
|
|
"""
|
|
if button != 1 or not urwid.util.is_mouse_press(event):
|
|
return False
|
|
|
|
self._emit('click')
|
|
return True
|
|
|
|
class MessageEdit(urwid.Edit):
|
|
def keypress(self, size, key):
|
|
if key == "ctrl d":
|
|
self.delegate.send_message()
|
|
elif key == "ctrl p":
|
|
self.delegate.paper_message()
|
|
elif key == "ctrl a":
|
|
self.delegate.attach_file()
|
|
elif key == "ctrl s":
|
|
self.delegate.save_focused_attachments()
|
|
elif key == "ctrl k":
|
|
self.delegate.clear_editor()
|
|
elif key == "up":
|
|
y = self.get_cursor_coords(size)[1]
|
|
if y == 0:
|
|
if self.delegate.full_editor_active and self.name == "title_editor":
|
|
self.delegate.frame.focus_position = "body"
|
|
elif not self.delegate.full_editor_active and self.name == "content_editor":
|
|
self.delegate.frame.focus_position = "body"
|
|
else:
|
|
return super(MessageEdit, self).keypress(size, key)
|
|
else:
|
|
return super(MessageEdit, self).keypress(size, key)
|
|
else:
|
|
return super(MessageEdit, self).keypress(size, key)
|
|
|
|
|
|
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"
|
|
else:
|
|
return super(ConversationFrame, self).keypress(size, key)
|
|
elif key == "ctrl k":
|
|
self.delegate.clear_editor()
|
|
else:
|
|
return super(ConversationFrame, self).keypress(size, key)
|
|
|
|
class ConversationWidget(urwid.WidgetWrap):
|
|
def __init__(self, source_hash, delegate):
|
|
self.app = nomadnet.NomadNetworkApp.get_shared_instance()
|
|
g = self.app.ui.glyphs
|
|
self.delegate = delegate
|
|
if source_hash == None:
|
|
self.frame = None
|
|
display_widget = urwid.LineBox(urwid.Filler(urwid.Text("\n No conversation selected"), "top"))
|
|
super().__init__(display_widget)
|
|
else:
|
|
if source_hash in ConversationsDisplay.cached_conversation_widgets:
|
|
return ConversationsDisplay.cached_conversation_widgets[source_hash]
|
|
else:
|
|
self.source_hash = source_hash
|
|
self.conversation = nomadnet.Conversation(source_hash, nomadnet.NomadNetworkApp.get_shared_instance())
|
|
self.message_widgets = []
|
|
self.sort_by_timestamp = False
|
|
self.pending_attachments = []
|
|
self.dialog_active = False
|
|
|
|
self.update_message_widgets()
|
|
|
|
self.conversation.register_changed_callback(self._on_conversation_changed_from_callback)
|
|
|
|
#title_editor = MessageEdit(caption="\u270E", edit_text="", multiline=False)
|
|
title_editor = MessageEdit(caption="", edit_text="", multiline=False)
|
|
title_editor.delegate = self
|
|
title_editor.name = "title_editor"
|
|
|
|
#msg_editor = MessageEdit(caption="\u270E", edit_text="", multiline=True)
|
|
msg_editor = MessageEdit(caption="", edit_text="", multiline=True)
|
|
msg_editor.delegate = self
|
|
msg_editor.name = "content_editor"
|
|
|
|
self.peer_info_widget = urwid.AttrMap(urwid.Text(""), "msg_header_sent")
|
|
self._update_peer_info()
|
|
|
|
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"
|
|
|
|
title_columns = urwid.Columns([
|
|
(8, urwid.Text("Title")),
|
|
urwid.AttrMap(title_editor, "msg_editor"),
|
|
])
|
|
|
|
content_columns = urwid.Columns([
|
|
(8, urwid.Text("Content")),
|
|
urwid.AttrMap(msg_editor, "msg_editor")
|
|
])
|
|
|
|
self.full_editor = urwid.Pile([
|
|
title_columns,
|
|
content_columns
|
|
])
|
|
self.full_editor.name = "full_editor"
|
|
|
|
self.content_editor = msg_editor
|
|
self.title_editor = title_editor
|
|
self.full_editor_active = False
|
|
|
|
self.frame = ConversationFrame(
|
|
self.messagelist,
|
|
header=header,
|
|
footer=self.minimal_editor,
|
|
focus_part="footer"
|
|
)
|
|
self.frame.delegate = self
|
|
|
|
self.display_widget = urwid.LineBox(
|
|
self.frame
|
|
)
|
|
|
|
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)
|
|
else: return strip_modifiers(name)
|
|
|
|
g = self.app.ui.glyphs
|
|
source_hash_bytes = bytes.fromhex(self.source_hash)
|
|
|
|
display_name = self.app.directory.display_name(source_hash_bytes)
|
|
app_data = None
|
|
if display_name is None or self.app.message_router.get_outbound_stamp_cost(source_hash_bytes) is None:
|
|
app_data = RNS.Identity.recall_app_data(source_hash_bytes)
|
|
|
|
if display_name is None:
|
|
if app_data:
|
|
display_name = san(LXMF.display_name_from_app_data(app_data))
|
|
if display_name is None:
|
|
display_name = RNS.prettyhexrep(source_hash_bytes)
|
|
|
|
stamp_cost = self.app.message_router.get_outbound_stamp_cost(source_hash_bytes)
|
|
if stamp_cost is None and app_data:
|
|
stamp_cost = LXMF.stamp_cost_from_app_data(app_data)
|
|
|
|
hops = RNS.Transport.hops_to(source_hash_bytes)
|
|
if hops >= RNS.Transport.PATHFINDER_M:
|
|
hops_str = "unknown"
|
|
else:
|
|
hops_str = str(hops)+" hop" + ("s" if hops != 1 else "")
|
|
|
|
right_parts = []
|
|
if stamp_cost is not None:
|
|
right_parts.append("Stamp: "+str(stamp_cost))
|
|
right_parts.append(g["speed"]+hops_str)
|
|
|
|
left = " "+display_name
|
|
right = " ".join(right_parts)+" "
|
|
self.peer_info_widget.original_widget.set_text(left+" | "+right)
|
|
|
|
def clear_history_dialog(self):
|
|
def dismiss_dialog(sender):
|
|
self.dialog_open = False
|
|
self.conversation_changed(None)
|
|
|
|
def confirmed(sender):
|
|
self.dialog_open = False
|
|
self.conversation.clear_history()
|
|
self.conversation_changed(None)
|
|
|
|
|
|
dialog = DialogLineBox(
|
|
urwid.Pile([
|
|
urwid.Text("Clear conversation history\n", align=urwid.CENTER),
|
|
urwid.Columns([
|
|
(urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=confirmed)),
|
|
(urwid.WEIGHT, 0.1, urwid.Text("")),
|
|
(urwid.WEIGHT, 0.45, urwid.Button("No", on_press=dismiss_dialog)),
|
|
])
|
|
]), title="?"
|
|
)
|
|
dialog.delegate = self
|
|
bottom = self.messagelist
|
|
|
|
overlay = urwid.Overlay(
|
|
dialog,
|
|
bottom,
|
|
align=urwid.CENTER,
|
|
width=34,
|
|
valign=urwid.MIDDLE,
|
|
height=urwid.PACK,
|
|
left=2,
|
|
right=2,
|
|
)
|
|
|
|
self.frame.contents["body"] = (overlay, self.frame.options())
|
|
self.frame.focus_position = "body"
|
|
|
|
def _build_footer(self):
|
|
g = self.app.ui.glyphs
|
|
if self.full_editor_active:
|
|
editor = self.full_editor
|
|
else:
|
|
editor = self.minimal_editor
|
|
|
|
if self.pending_attachments:
|
|
attachment_texts = []
|
|
for path in self.pending_attachments:
|
|
attachment_texts.append(os.path.basename(path))
|
|
indicator = urwid.AttrMap(
|
|
urwid.Text(g["file"]+" "+str(len(self.pending_attachments))+" file(s): "+", ".join(attachment_texts)),
|
|
"msg_header_sent",
|
|
)
|
|
return urwid.Pile([indicator, editor])
|
|
else:
|
|
return editor
|
|
|
|
def toggle_editor(self):
|
|
if self.full_editor_active:
|
|
self.full_editor_active = False
|
|
else:
|
|
self.full_editor_active = True
|
|
self.frame.contents["footer"] = (self._build_footer(), None)
|
|
|
|
def check_editor_allowed(self):
|
|
g = self.app.ui.glyphs
|
|
if self.frame:
|
|
allowed = nomadnet.NomadNetworkApp.get_shared_instance().directory.is_known(bytes.fromhex(self.source_hash))
|
|
if allowed:
|
|
self.frame.contents["footer"] = (self._build_footer(), None)
|
|
else:
|
|
warning = urwid.AttrMap(
|
|
urwid.Padding(urwid.Text(
|
|
"\n"+g["info"]+"\n\nYou cannot currently message this peer, since its identity keys are not known. "
|
|
"The keys have been requested from the network and should arrive shortly, if available. "
|
|
"Close this conversation and reopen it to try again.\n\n"
|
|
"To query the network manually, select this conversation in the conversation list, "
|
|
"press Ctrl-E, and use the query button.\n",
|
|
align=urwid.CENTER,
|
|
)),
|
|
"msg_header_caution",
|
|
)
|
|
self.frame.contents["footer"] = (warning, None)
|
|
|
|
def toggle_focus_area(self):
|
|
name = ""
|
|
try:
|
|
name = self.frame.get_focus_widgets()[0].name
|
|
except Exception as e:
|
|
pass
|
|
|
|
if name == "messagelist":
|
|
self.frame.focus_position = "footer"
|
|
elif name == "minimal_editor" or name == "full_editor":
|
|
self.frame.focus_position = "body"
|
|
|
|
def keypress(self, size, key):
|
|
if key == "tab":
|
|
self.toggle_focus_area()
|
|
elif key == "ctrl w":
|
|
self.close()
|
|
elif key == "ctrl u":
|
|
self.conversation.purge_failed()
|
|
self.conversation_changed(None)
|
|
elif key == "ctrl t":
|
|
self.toggle_editor()
|
|
elif key == "ctrl x":
|
|
self.clear_history_dialog()
|
|
elif key == "ctrl g":
|
|
nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.sub_displays.conversations_display.toggle_fullscreen()
|
|
elif key == "ctrl o":
|
|
self.sort_by_timestamp ^= True
|
|
self.conversation_changed(None)
|
|
elif key == "ctrl a":
|
|
self.attach_file()
|
|
elif key == "ctrl s":
|
|
self.save_focused_attachments()
|
|
else:
|
|
return super(ConversationWidget, self).keypress(size, key)
|
|
|
|
def _on_conversation_changed_from_callback(self, conversation):
|
|
self.delegate._wake(lambda: self.conversation_changed(conversation))
|
|
|
|
def conversation_changed(self, conversation):
|
|
if hasattr(self, "peer_info_widget"):
|
|
self._update_peer_info()
|
|
self.update_message_widgets(replace = True)
|
|
|
|
def update_message_widgets(self, replace = False):
|
|
self.message_widgets = []
|
|
added_hashes = []
|
|
needs_index = []
|
|
for message in self.conversation.messages:
|
|
message_hash = message.get_hash()
|
|
if not message_hash in added_hashes:
|
|
added_hashes.append(message_hash)
|
|
was_loaded = message.loaded
|
|
try:
|
|
message_widget = LXMessageWidget(message, theme=self.app.config["textui"]["theme"], conversation_widget=self)
|
|
except Exception as e:
|
|
RNS.log("Skipping message loading for "+str(message.file_path)+" due to error: "+str(e), RNS.LOG_WARNING)
|
|
message.unload()
|
|
continue
|
|
self.message_widgets.append(message_widget)
|
|
if not was_loaded and message.loaded:
|
|
needs_index.append(message)
|
|
message.unload()
|
|
|
|
if needs_index:
|
|
try:
|
|
ConversationMessage.write_index(
|
|
self.conversation.messages_path, needs_index)
|
|
except Exception:
|
|
pass
|
|
|
|
if self.sort_by_timestamp:
|
|
self.message_widgets.sort(key=lambda m: m.timestamp, reverse=False)
|
|
else:
|
|
self.message_widgets.sort(key=lambda m: m.sort_timestamp, reverse=False)
|
|
|
|
from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox
|
|
self.messagelist = IndicativeListBox(self.message_widgets, position = len(self.message_widgets)-1)
|
|
self.messagelist.name = "messagelist"
|
|
if replace:
|
|
self.frame.contents["body"] = (self.messagelist, None)
|
|
nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.draw_screen()
|
|
|
|
|
|
def clear_editor(self):
|
|
self.content_editor.set_edit_text("")
|
|
self.title_editor.set_edit_text("")
|
|
self.pending_attachments = []
|
|
self.frame.contents["footer"] = (self._build_footer(), None)
|
|
|
|
def _collect_attachment_refs(self):
|
|
g = self.app.ui.glyphs
|
|
refs = []
|
|
sorted_messages = sorted(self.conversation.messages, key=lambda m: m.sort_timestamp, reverse=True)
|
|
for conv_message in sorted_messages:
|
|
if not conv_message.has_attachments():
|
|
continue
|
|
|
|
cached_names = conv_message._cached_attachment_names or []
|
|
att_file_idx = 0
|
|
for atype, aname, *arest in cached_names:
|
|
asize = arest[0] if arest else 0
|
|
glyph = g["file"] if atype == "file" else g[atype]
|
|
label = glyph+" "+aname
|
|
if asize > 0:
|
|
label += " ("+_format_size(asize)+")"
|
|
if atype == "file":
|
|
refs.append((label, aname, conv_message, "file", att_file_idx))
|
|
att_file_idx += 1
|
|
else:
|
|
refs.append((label, aname, conv_message, atype, 0))
|
|
|
|
return refs
|
|
|
|
def save_focused_attachments(self):
|
|
g = self.app.ui.glyphs
|
|
self.dialog_active = True
|
|
|
|
try:
|
|
attachment_items = self._collect_attachment_refs()
|
|
except Exception as e:
|
|
RNS.log("Error collecting attachments: "+str(e), RNS.LOG_ERROR)
|
|
attachment_items = []
|
|
|
|
save_dir = self.app.attachment_save_path if self.app.attachment_save_path else self.app.downloads_path
|
|
|
|
def dismiss_dialog(sender):
|
|
self.dialog_active = False
|
|
self.conversation_changed(None)
|
|
|
|
if not attachment_items:
|
|
dialog = DialogLineBox(
|
|
urwid.Pile([
|
|
urwid.Text("No attachments in this conversation.\n"),
|
|
urwid.Columns([
|
|
(urwid.WEIGHT, 0.6, urwid.Text("")),
|
|
(urwid.WEIGHT, 0.4, urwid.Button("OK", on_press=dismiss_dialog)),
|
|
])
|
|
]), title="Attachments"
|
|
)
|
|
dialog.delegate = self
|
|
bottom = self.messagelist
|
|
overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=45, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2)
|
|
self.frame.contents["body"] = (overlay, self.frame.options())
|
|
self.frame.focus_position = "body"
|
|
return
|
|
|
|
checkboxes = []
|
|
for label, filename, conv_msg, field_type, field_index in attachment_items:
|
|
cb = urwid.CheckBox(label, state=False)
|
|
cb._attachment_filename = filename
|
|
cb._conv_message = conv_msg
|
|
cb._field_type = field_type
|
|
cb._field_index = field_index
|
|
checkboxes.append(cb)
|
|
|
|
status_text = urwid.Text("")
|
|
|
|
def do_save(sender):
|
|
saved = []
|
|
errors = []
|
|
for cb in checkboxes:
|
|
if cb.get_state():
|
|
try:
|
|
src_path = cb._conv_message.get_attachment_file_path(cb._field_type, cb._field_index)
|
|
if src_path and os.path.isfile(src_path):
|
|
path = _copy_attachment_to_dest(cb._attachment_filename, src_path)
|
|
saved.append(path)
|
|
except Exception as e:
|
|
errors.append(str(e))
|
|
|
|
if saved:
|
|
lines = [g["check"]+" Copied "+str(len(saved))+" file(s) to "+save_dir+":"]
|
|
for p in saved:
|
|
lines.append(" "+os.path.basename(p))
|
|
if errors:
|
|
lines.append(g["cross"]+" "+str(len(errors))+" failed")
|
|
status_text.set_text("\n".join(lines))
|
|
elif errors:
|
|
status_text.set_text(g["cross"]+" Failed: "+errors[0])
|
|
else:
|
|
status_text.set_text("No files selected")
|
|
|
|
dialog_widgets = list(checkboxes)
|
|
dialog_widgets.append(urwid.Divider(g["divider1"]))
|
|
dialog_widgets.append(urwid.Text("Copy to: "+save_dir))
|
|
dialog_widgets.append(status_text)
|
|
dialog_widgets.append(urwid.Text(""))
|
|
dialog_widgets.append(urwid.Columns([
|
|
(urwid.WEIGHT, 0.45, urwid.Button("Copy to Downloads", on_press=do_save)),
|
|
(urwid.WEIGHT, 0.1, urwid.Text("")),
|
|
(urwid.WEIGHT, 0.45, urwid.Button("Close", on_press=dismiss_dialog)),
|
|
]))
|
|
|
|
dialog = DialogLineBox(urwid.ListBox(urwid.SimpleFocusListWalker(dialog_widgets)), title="Attachments")
|
|
dialog.delegate = self
|
|
bottom = self.messagelist
|
|
|
|
overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=("relative", 80), valign=urwid.MIDDLE, height=("relative", 80), left=2, right=2)
|
|
self.frame.contents["body"] = (overlay, self.frame.options())
|
|
self.frame.focus_position = "body"
|
|
|
|
def send_message(self):
|
|
content = self.content_editor.get_edit_text()
|
|
title = self.title_editor.get_edit_text()
|
|
if not content == "":
|
|
fields = None
|
|
if self.pending_attachments:
|
|
file_attachments = []
|
|
for file_path in self.pending_attachments:
|
|
try:
|
|
with open(file_path, "rb") as af:
|
|
file_data = af.read()
|
|
file_name = os.path.basename(file_path)
|
|
file_attachments.append([file_name, file_data])
|
|
except Exception as e:
|
|
RNS.log("Error reading attachment "+str(file_path)+": "+str(e), RNS.LOG_ERROR)
|
|
|
|
if file_attachments:
|
|
fields = {LXMF.FIELD_FILE_ATTACHMENTS: file_attachments}
|
|
|
|
if self.app.compose_markdown:
|
|
if not fields: fields = {}
|
|
fields[LXMF.FIELD_RENDERER] = LXMF.RENDERER_MARKDOWN
|
|
|
|
if self.conversation.send(content, title, fields=fields):
|
|
self.clear_editor()
|
|
|
|
def attach_file(self):
|
|
self.dialog_active = True
|
|
browser = FileBrowserDialog(self)
|
|
bottom = self.messagelist
|
|
overlay = urwid.Overlay(browser, bottom, align=urwid.CENTER, width=("relative", 90), valign=urwid.MIDDLE, height=("relative", 80), left=2, right=2)
|
|
self.frame.contents["body"] = (overlay, self.frame.options())
|
|
self.frame.focus_position = "body"
|
|
|
|
def file_browser_closed(self):
|
|
self.dialog_active = False
|
|
self.frame.contents["footer"] = (self._build_footer(), None)
|
|
self.conversation_changed(None)
|
|
|
|
def paper_message_saved(self, path):
|
|
g = self.app.ui.glyphs
|
|
def dismiss_dialog(sender):
|
|
self.dialog_open = False
|
|
self.conversation_changed(None)
|
|
|
|
dialog = DialogLineBox(
|
|
urwid.Pile([
|
|
urwid.Text("The paper message was saved to:\n\n"+str(path)+"\n", align=urwid.CENTER),
|
|
urwid.Columns([
|
|
(urwid.WEIGHT, 0.6, urwid.Text("")),
|
|
(urwid.WEIGHT, 0.4, urwid.Button("OK", on_press=dismiss_dialog)),
|
|
])
|
|
]), title=g["papermsg"].replace(" ", "")
|
|
)
|
|
dialog.delegate = self
|
|
bottom = self.messagelist
|
|
|
|
overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=60, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2)
|
|
|
|
self.frame.contents["body"] = (overlay, self.frame.options())
|
|
self.frame.focus_position = "body"
|
|
|
|
def print_paper_message_qr(self):
|
|
content = self.content_editor.get_edit_text()
|
|
title = self.title_editor.get_edit_text()
|
|
if not content == "":
|
|
if self.conversation.paper_output(content, title):
|
|
self.clear_editor()
|
|
else:
|
|
self.paper_message_failed()
|
|
|
|
def save_paper_message_qr(self):
|
|
content = self.content_editor.get_edit_text()
|
|
title = self.title_editor.get_edit_text()
|
|
if not content == "":
|
|
output_result = self.conversation.paper_output(content, title, mode="save_qr")
|
|
if output_result != False:
|
|
self.clear_editor()
|
|
self.paper_message_saved(output_result)
|
|
else:
|
|
self.paper_message_failed()
|
|
|
|
def save_paper_message_uri(self):
|
|
content = self.content_editor.get_edit_text()
|
|
title = self.title_editor.get_edit_text()
|
|
if not content == "":
|
|
output_result = self.conversation.paper_output(content, title, mode="save_uri")
|
|
if output_result != False:
|
|
self.clear_editor()
|
|
self.paper_message_saved(output_result)
|
|
else:
|
|
self.paper_message_failed()
|
|
|
|
def paper_message(self):
|
|
def dismiss_dialog(sender):
|
|
self.dialog_open = False
|
|
self.conversation_changed(None)
|
|
|
|
def print_qr(sender):
|
|
dismiss_dialog(self)
|
|
self.print_paper_message_qr()
|
|
|
|
def save_qr(sender):
|
|
dismiss_dialog(self)
|
|
self.save_paper_message_qr()
|
|
|
|
def save_uri(sender):
|
|
dismiss_dialog(self)
|
|
self.save_paper_message_uri()
|
|
|
|
dialog = DialogLineBox(
|
|
urwid.Pile([
|
|
urwid.Text(
|
|
"Select the desired paper message output method.\nSaved files will be written to:\n\n"+str(self.app.downloads_path)+"\n",
|
|
align=urwid.CENTER,
|
|
),
|
|
urwid.Columns([
|
|
(urwid.WEIGHT, 0.5, urwid.Button("Print QR", on_press=print_qr)),
|
|
(urwid.WEIGHT, 0.1, urwid.Text("")),
|
|
(urwid.WEIGHT, 0.5, urwid.Button("Save QR", on_press=save_qr)),
|
|
(urwid.WEIGHT, 0.1, urwid.Text("")),
|
|
(urwid.WEIGHT, 0.5, urwid.Button("Save URI", on_press=save_uri)),
|
|
(urwid.WEIGHT, 0.1, urwid.Text("")),
|
|
(urwid.WEIGHT, 0.5, urwid.Button("Cancel", on_press=dismiss_dialog))
|
|
])
|
|
]), title="Create Paper Message"
|
|
)
|
|
dialog.delegate = self
|
|
bottom = self.messagelist
|
|
|
|
overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=60, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2)
|
|
|
|
self.frame.contents["body"] = (overlay, self.frame.options())
|
|
self.frame.focus_position = "body"
|
|
|
|
def paper_message_failed(self):
|
|
def dismiss_dialog(sender):
|
|
self.dialog_open = False
|
|
self.conversation_changed(None)
|
|
|
|
dialog = DialogLineBox(
|
|
urwid.Pile([
|
|
urwid.Text(
|
|
"Could not output paper message,\ncheck your settings. See the log\nfile for any error messages.\n",
|
|
align=urwid.CENTER,
|
|
),
|
|
urwid.Columns([
|
|
(urwid.WEIGHT, 0.6, urwid.Text("")),
|
|
(urwid.WEIGHT, 0.4, urwid.Button("OK", on_press=dismiss_dialog)),
|
|
])
|
|
]), title="!"
|
|
)
|
|
dialog.delegate = self
|
|
bottom = self.messagelist
|
|
|
|
overlay = urwid.Overlay(dialog, bottom, align=urwid.CENTER, width=34, valign=urwid.MIDDLE, height=urwid.PACK, left=2, right=2)
|
|
|
|
self.frame.contents["body"] = (overlay, self.frame.options())
|
|
self.frame.focus_position = "body"
|
|
|
|
def close(self):
|
|
self.delegate.close_conversation(self)
|
|
|
|
|
|
class LXMessageWidget(urwid.WidgetWrap):
|
|
mdc = MarkdownToMicron(max_width=80, syntax_highlighter=SyntaxHighlighter(), url_scope=None)
|
|
|
|
def __init__(self, message, theme=THEME_DARK, conversation_widget=None):
|
|
app = nomadnet.NomadNetworkApp.get_shared_instance()
|
|
g = app.ui.glyphs
|
|
self._conversation_widget = conversation_widget
|
|
self.timestamp = message.get_timestamp()
|
|
self.sort_timestamp = message.sort_timestamp
|
|
self.transfer_done = False
|
|
self._live_lxm = None
|
|
|
|
msg_hash = message.get_hash()
|
|
msg_state = message.get_state()
|
|
msg_source_hash = message._cached_source_hash
|
|
msg_method = message._cached_method
|
|
time_format = app.time_format
|
|
message_time = datetime.fromtimestamp(self.timestamp)
|
|
renderer = message.content_renderer()
|
|
encryption_string = ""
|
|
if message.get_transport_encrypted():
|
|
encryption_string = " "+g["encrypted"]
|
|
else:
|
|
encryption_string = " "+g["plaintext"]
|
|
|
|
title_string = relative_time(self.timestamp)+" | "+message_time.strftime(time_format)+encryption_string
|
|
|
|
is_outbound = False
|
|
if msg_source_hash is None:
|
|
header_style = "msg_header_failed"
|
|
title_string = g["warning"]+" "+title_string
|
|
elif app.lxmf_destination.hash == msg_source_hash:
|
|
is_outbound = True
|
|
if msg_state == LXMF.LXMessage.DELIVERED:
|
|
header_style = "msg_header_delivered"
|
|
title_string = g["check"]+" "+g["arrow_r"]+" "+title_string
|
|
elif msg_state == LXMF.LXMessage.FAILED:
|
|
header_style = "msg_header_failed"
|
|
title_string = g["cross"]+" "+g["arrow_r"]+" "+title_string
|
|
elif msg_state == LXMF.LXMessage.REJECTED:
|
|
header_style = "msg_header_failed"
|
|
title_string = g["cross"]+" "+g["arrow_r"]+" Rejected "+title_string
|
|
elif msg_method == LXMF.LXMessage.PROPAGATED and msg_state == LXMF.LXMessage.SENT:
|
|
header_style = "msg_header_propagated"
|
|
title_string = g["sent"]+" "+g["arrow_r"]+" "+title_string
|
|
elif msg_method == LXMF.LXMessage.PAPER and msg_state == LXMF.LXMessage.PAPER:
|
|
header_style = "msg_header_propagated"
|
|
title_string = g["papermsg"]+" "+g["arrow_r"]+" "+title_string
|
|
elif msg_state == LXMF.LXMessage.SENT:
|
|
header_style = "msg_header_sent"
|
|
title_string = g["sent"]+" "+g["arrow_r"]+" "+title_string
|
|
else:
|
|
header_style = "msg_header_sent"
|
|
title_string = g["arrow_r"]+" "+title_string
|
|
else:
|
|
if message.signature_validated():
|
|
header_style = "msg_header_ok"
|
|
title_string = g["check"]+" "+g["arrow_l"]+" "+title_string
|
|
else:
|
|
header_style = "msg_header_caution"
|
|
title_string = g["warning"]+" "+g["arrow_l"]+" "+message.get_signature_description() + "\n " + title_string
|
|
|
|
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 []
|
|
|
|
if has_attachments and cached_names:
|
|
attachment_strings = []
|
|
for atype, aname, *arest in cached_names:
|
|
attachment_strings.append(g[atype if atype != "file" else "file"]+" "+aname)
|
|
title_string += " | " + " ".join(attachment_strings)
|
|
|
|
content_text = message.get_content()
|
|
|
|
if content_text and app.config["textui"]["clipboard_copy"]:
|
|
copy_glyph = g.get("copy", "[C]")
|
|
check_glyph = g.get("check", "v").center(len(copy_glyph))
|
|
copy_icon = ClickableIcon(copy_glyph)
|
|
|
|
conv_widget = self._conversation_widget
|
|
def on_copy_click(icon=copy_icon, content=content_text, cg=copy_glyph, kg=check_glyph, cw=conv_widget):
|
|
osc52_copy(content)
|
|
icon.set_text(kg)
|
|
def _restore(loop, user_data):
|
|
icon.set_text(cg)
|
|
try:
|
|
app.ui.loop.set_alarm_in(2.0, _restore)
|
|
except Exception:
|
|
icon.set_text(cg)
|
|
if cw is not None and cw.frame is not None:
|
|
def _refocus(loop, user_data):
|
|
try:
|
|
cw.frame.focus_position = "footer"
|
|
except Exception:
|
|
pass
|
|
try:
|
|
app.ui.loop.set_alarm_in(0, _refocus)
|
|
except Exception:
|
|
pass
|
|
copy_icon._on_click = on_copy_click
|
|
|
|
copy_width = len(copy_glyph) + 2
|
|
title_row = urwid.Columns([
|
|
("weight", 1, urwid.Text(title_string)),
|
|
(copy_width, urwid.Padding(copy_icon, left=1, right=1)),
|
|
])
|
|
title = urwid.AttrMap(title_row, header_style)
|
|
else:
|
|
title = urwid.AttrMap(urwid.Text(title_string), header_style)
|
|
|
|
self.progress_widget = urwid.Text("")
|
|
self.progress_attr = urwid.AttrMap(self.progress_widget, "progress_full")
|
|
|
|
content_lines = content_text.split("\n")
|
|
markdown = renderer == LXMF.RENDERER_MARKDOWN
|
|
|
|
default_fg = "bbb" if theme == THEME_DARK else "444"
|
|
if markdown:
|
|
formatted = self.mdc.format_block(content_text)
|
|
message_body = strip_non_formatting_tags(formatted)
|
|
rendered = markup_to_attrmaps(strip_modifiers(message_body), url_delegate=None, fg_color=default_fg, bg_color=None)
|
|
content_pile = urwid.Padding(urwid.Pile(rendered), left=2, right=2)
|
|
|
|
else: indented = "\n".join(" "+line for line in content_lines)
|
|
|
|
pile_widgets = [title]
|
|
|
|
if is_outbound and msg_state is not None and msg_state < LXMF.LXMessage.SENT and msg_hash is not None:
|
|
try:
|
|
for pending in app.message_router.pending_outbound:
|
|
if pending.hash == msg_hash:
|
|
if pending.representation == LXMF.LXMessage.RESOURCE:
|
|
self._live_lxm = pending
|
|
break
|
|
except Exception:
|
|
pass
|
|
|
|
if self._live_lxm is not None:
|
|
pct = int(self._live_lxm.progress * 100)
|
|
bar_width = 20
|
|
filled = int(bar_width * self._live_lxm.progress)
|
|
if app.ui.colormode >= 256:
|
|
bar = "\u2588" * filled + "\u2591" * (bar_width - filled)
|
|
else:
|
|
bar = "#" * filled + "-" * (bar_width - filled)
|
|
self.progress_widget.set_text(" ["+bar+"] "+str(pct)+"%")
|
|
pile_widgets.append(self.progress_attr)
|
|
self._start_progress_poll()
|
|
|
|
if markdown: pile_widgets.append(content_pile)
|
|
else: 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]
|
|
asize = arest[0] if arest else 0
|
|
label = " "+glyph+" "+aname
|
|
if asize > 0:
|
|
label += " ("+_format_size(asize)+")"
|
|
if atype == "file":
|
|
pile_widgets.append(ClickableAttachment(label, aname, message, "file", att_file_idx))
|
|
att_file_idx += 1
|
|
else:
|
|
pile_widgets.append(ClickableAttachment(label, aname, message, atype))
|
|
|
|
pile_widgets.append(urwid.Text(""))
|
|
|
|
super().__init__(urwid.Pile(pile_widgets))
|
|
|
|
def _start_progress_poll(self):
|
|
try:
|
|
loop = nomadnet.NomadNetworkApp.get_shared_instance().ui.loop
|
|
if loop:
|
|
loop.set_alarm_in(0.3, self._poll_progress)
|
|
except Exception:
|
|
pass
|
|
|
|
def _poll_progress(self, loop=None, user_data=None):
|
|
if self.transfer_done:
|
|
return
|
|
|
|
if self._live_lxm is None:
|
|
self.transfer_done = True
|
|
return
|
|
|
|
app = nomadnet.NomadNetworkApp.get_shared_instance()
|
|
g = app.ui.glyphs
|
|
progress = self._live_lxm.progress
|
|
state = self._live_lxm.state
|
|
pct = int(progress * 100)
|
|
|
|
if state == LXMF.LXMessage.FAILED:
|
|
self.progress_widget.set_text(" "+g["cross"]+" Transfer failed")
|
|
self.transfer_done = True
|
|
self._live_lxm = None
|
|
elif state == LXMF.LXMessage.REJECTED:
|
|
self.progress_widget.set_text(" "+g["cross"]+" Rejected: too large or not accepted")
|
|
self.transfer_done = True
|
|
self._live_lxm = None
|
|
elif state >= LXMF.LXMessage.SENT:
|
|
self.progress_widget.set_text("")
|
|
self.transfer_done = True
|
|
self._live_lxm = None
|
|
else:
|
|
bar_width = 20
|
|
filled = int(bar_width * progress)
|
|
if app.ui.colormode >= 256:
|
|
bar = "\u2588" * filled + "\u2591" * (bar_width - filled)
|
|
else:
|
|
bar = "#" * filled + "-" * (bar_width - filled)
|
|
self.progress_widget.set_text(" ["+bar+"] "+str(pct)+"%")
|
|
|
|
if not self.transfer_done:
|
|
try:
|
|
ui_loop = app.ui.loop
|
|
if ui_loop:
|
|
ui_loop.set_alarm_in(0.3, self._poll_progress)
|
|
ui_loop.draw_screen()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
class ClickableAttachment(urwid.Text):
|
|
def __init__(self, label, filename, conv_message, field_type, field_index=0):
|
|
self.filename = filename
|
|
self.conv_message = conv_message
|
|
self.field_type = field_type
|
|
self.field_index = field_index
|
|
self.saved = False
|
|
super().__init__(label)
|
|
|
|
def mouse_event(self, size, event, button, x, y, focus):
|
|
if button == 1 and urwid.util.is_mouse_press(event):
|
|
self._save()
|
|
return True
|
|
return False
|
|
|
|
def _save(self):
|
|
if self.saved:
|
|
return
|
|
app = nomadnet.NomadNetworkApp.get_shared_instance()
|
|
g = app.ui.glyphs
|
|
try:
|
|
src_path = self.conv_message.get_attachment_file_path(self.field_type, self.field_index)
|
|
if src_path and os.path.isfile(src_path):
|
|
save_path = _copy_attachment_to_dest(self.filename, src_path)
|
|
else:
|
|
if self.field_type == "file":
|
|
attachments = self.conv_message.get_file_attachments()
|
|
if self.field_index < len(attachments):
|
|
att = attachments[self.field_index]
|
|
if isinstance(att, list) and len(att) >= 2:
|
|
data = att[1] if isinstance(att[1], bytes) else b""
|
|
else:
|
|
data = b""
|
|
else:
|
|
data = b""
|
|
elif self.field_type == "image":
|
|
data = self.conv_message.get_image()
|
|
data = data if isinstance(data, bytes) else b""
|
|
elif self.field_type == "audio":
|
|
data = self.conv_message.get_audio()
|
|
data = data if isinstance(data, bytes) else b""
|
|
else:
|
|
data = b""
|
|
self.conv_message.unload()
|
|
if not data:
|
|
return
|
|
save_path = _save_attachment_to_disk(self.filename, data)
|
|
|
|
self.saved = True
|
|
self.set_text(" "+g["check"]+" Copied to: "+save_path)
|
|
except Exception as e:
|
|
RNS.log("Error saving attachment: "+str(e), RNS.LOG_ERROR)
|
|
self.set_text(" "+g["cross"]+" Save failed: "+str(e))
|
|
|
|
|
|
def _resolve_attachment_save_path(filename):
|
|
app = nomadnet.NomadNetworkApp.get_shared_instance()
|
|
save_dir = app.attachment_save_path if app.attachment_save_path else app.downloads_path
|
|
if not os.path.isdir(save_dir):
|
|
os.makedirs(save_dir)
|
|
safe_name = ConversationMessage.safe_attachment_name(filename)
|
|
base_dir = os.path.realpath(save_dir) + os.sep
|
|
candidate = os.path.realpath(os.path.join(save_dir, safe_name))
|
|
if not (candidate + os.sep).startswith(base_dir):
|
|
raise OSError(13, os.strerror(13))
|
|
counter = 0
|
|
base, ext = os.path.splitext(safe_name)
|
|
while os.path.isfile(candidate):
|
|
counter += 1
|
|
candidate = os.path.realpath(os.path.join(save_dir, base+"_"+str(counter)+ext))
|
|
if not (candidate + os.sep).startswith(base_dir):
|
|
raise OSError(13, os.strerror(13))
|
|
return candidate
|
|
|
|
|
|
def _copy_attachment_to_dest(filename, src_path):
|
|
save_path = _resolve_attachment_save_path(filename)
|
|
shutil.copy2(src_path, save_path)
|
|
return save_path
|
|
|
|
|
|
def _save_attachment_to_disk(filename, data):
|
|
save_path = _resolve_attachment_save_path(filename)
|
|
with open(save_path, "wb") as f:
|
|
f.write(data)
|
|
return save_path
|
|
|
|
|
|
class FileBrowserEntry(urwid.WidgetWrap):
|
|
signals = ["click"]
|
|
|
|
def __init__(self, name, full_path, is_dir=False, is_parent=False, selected=False):
|
|
self.full_path = full_path
|
|
self.name = name
|
|
self.is_dir = is_dir
|
|
self.is_parent = is_parent
|
|
self.selected = selected
|
|
g = nomadnet.NomadNetworkApp.get_shared_instance().ui.glyphs
|
|
if is_parent:
|
|
display = g["arrow_l"]+" .."
|
|
elif is_dir:
|
|
display = g["arrow_r"]+" "+name+"/"
|
|
elif selected:
|
|
display = g["check"]+" "+name
|
|
else:
|
|
display = " "+name
|
|
self.text_widget = urwid.SelectableIcon(display, 0)
|
|
if is_dir or is_parent:
|
|
style = "list_trusted"
|
|
focus_style = "list_focus"
|
|
elif selected:
|
|
style = "list_trusted"
|
|
focus_style = "list_focus_trusted"
|
|
else:
|
|
style = "list_unknown"
|
|
focus_style = "list_focus"
|
|
display_widget = urwid.AttrMap(self.text_widget, style, focus_style)
|
|
super().__init__(display_widget)
|
|
|
|
def keypress(self, size, key):
|
|
if key == "enter":
|
|
self._emit("click")
|
|
else:
|
|
return key
|
|
|
|
def mouse_event(self, size, event, button, x, y, focus):
|
|
if button == 1 and urwid.util.is_mouse_press(event):
|
|
self._emit("click")
|
|
return True
|
|
return False
|
|
|
|
|
|
class FileBrowserDialog(urwid.WidgetWrap):
|
|
def __init__(self, delegate):
|
|
self.delegate = delegate
|
|
app = nomadnet.NomadNetworkApp.get_shared_instance()
|
|
self.g = app.ui.glyphs
|
|
self.current_path = os.path.expanduser("~")
|
|
|
|
self.path_label = urwid.Text("")
|
|
self.status_label = urwid.Text("")
|
|
self.file_walker = urwid.SimpleFocusListWalker([])
|
|
self.file_listbox = urwid.ListBox(self.file_walker)
|
|
|
|
self.button_columns = urwid.Columns([
|
|
(urwid.WEIGHT, 0.45, urwid.Button("Done", on_press=self._dismiss)),
|
|
(urwid.WEIGHT, 0.1, urwid.Text("")),
|
|
(urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=self._cancel)),
|
|
])
|
|
|
|
header_pile = urwid.Pile([
|
|
self.path_label,
|
|
self.status_label,
|
|
urwid.Divider(self.g["divider1"]),
|
|
])
|
|
|
|
footer_pile = urwid.Pile([
|
|
urwid.Divider(self.g["divider1"]),
|
|
self.button_columns,
|
|
])
|
|
|
|
self._populate()
|
|
|
|
self.browser_frame = urwid.Frame(
|
|
self.file_listbox,
|
|
header=header_pile,
|
|
footer=footer_pile,
|
|
)
|
|
|
|
linebox = urwid.LineBox(self.browser_frame, title="Attach File")
|
|
super().__init__(linebox)
|
|
|
|
def _update_status(self):
|
|
pending = self.delegate.pending_attachments
|
|
if pending:
|
|
names = [os.path.basename(p) for p in pending]
|
|
self.status_label.set_text(" "+self.g["file"]+" "+str(len(pending))+" selected: "+", ".join(names))
|
|
else:
|
|
self.status_label.set_text(" No files selected")
|
|
|
|
def _populate(self):
|
|
self.path_label.set_text(" "+self.current_path)
|
|
self._update_status()
|
|
|
|
focus_pos = None
|
|
try:
|
|
focus_pos = self.file_listbox.focus_position
|
|
except Exception:
|
|
pass
|
|
|
|
entries = []
|
|
parent = os.path.dirname(self.current_path)
|
|
if parent != self.current_path:
|
|
entry = FileBrowserEntry("..", parent, is_parent=True)
|
|
urwid.connect_signal(entry, "click", self._entry_clicked, entry)
|
|
entries.append(entry)
|
|
|
|
try:
|
|
items = sorted(os.listdir(self.current_path))
|
|
except PermissionError:
|
|
entries.append(urwid.Text(("error_text", " Permission denied")))
|
|
self.file_walker[:] = entries
|
|
return
|
|
|
|
dirs = []
|
|
files = []
|
|
for item in items:
|
|
if item.startswith("."):
|
|
continue
|
|
full = os.path.join(self.current_path, item)
|
|
if os.path.isdir(full):
|
|
dirs.append((item, full))
|
|
elif os.path.isfile(full):
|
|
files.append((item, full))
|
|
|
|
for name, full in dirs:
|
|
entry = FileBrowserEntry(name, full, is_dir=True)
|
|
urwid.connect_signal(entry, "click", self._entry_clicked, entry)
|
|
entries.append(entry)
|
|
|
|
for name, full in files:
|
|
is_selected = full in self.delegate.pending_attachments
|
|
entry = FileBrowserEntry(name, full, selected=is_selected)
|
|
urwid.connect_signal(entry, "click", self._entry_clicked, entry)
|
|
entries.append(entry)
|
|
|
|
if not dirs and not files:
|
|
entries.append(urwid.Text(("inactive_text", " (empty)")))
|
|
|
|
self.file_walker[:] = entries
|
|
if focus_pos is not None and focus_pos < len(entries):
|
|
self.file_listbox.set_focus(focus_pos)
|
|
elif entries:
|
|
self.file_listbox.set_focus(0)
|
|
|
|
def _entry_clicked(self, entry_widget, user_data=None):
|
|
entry = user_data if user_data else entry_widget
|
|
if entry.is_dir or entry.is_parent:
|
|
self.current_path = entry.full_path
|
|
self._populate()
|
|
else:
|
|
if entry.full_path in self.delegate.pending_attachments:
|
|
self.delegate.pending_attachments.remove(entry.full_path)
|
|
else:
|
|
self.delegate.pending_attachments.append(entry.full_path)
|
|
self.delegate.frame.contents["footer"] = (self.delegate._build_footer(), None)
|
|
self._populate()
|
|
|
|
def _dismiss(self, sender):
|
|
self.delegate.file_browser_closed()
|
|
|
|
def _cancel(self, sender):
|
|
self.delegate.pending_attachments.clear()
|
|
self.delegate.frame.contents["footer"] = (self.delegate._build_footer(), None)
|
|
self.delegate.file_browser_closed()
|
|
|
|
def keypress(self, size, key):
|
|
if key == "esc":
|
|
self.delegate.file_browser_closed()
|
|
return
|
|
result = super().keypress(size, key)
|
|
if result == "down" and self.browser_frame.focus_position == "body":
|
|
self.browser_frame.focus_position = "footer"
|
|
return
|
|
elif result == "up" and self.browser_frame.focus_position == "footer":
|
|
self.browser_frame.focus_position = "body"
|
|
return
|
|
return result
|
|
|
|
|
|
class SyncProgressBar(urwid.ProgressBar):
|
|
def get_text(self):
|
|
status = nomadnet.NomadNetworkApp.get_shared_instance().get_sync_status()
|
|
show_percent = nomadnet.NomadNetworkApp.get_shared_instance().sync_status_show_percent()
|
|
if show_percent:
|
|
return status+" "+super().get_text()
|
|
else:
|
|
return status
|