From 553ba92b4c59cb9ab403369a123ccf0411039cc8 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 13 May 2021 16:39:31 +0200 Subject: [PATCH] Implemented basic conversation list and chat functionality in Text UI --- nomadnet/ui/TextUI.py | 14 +- nomadnet/ui/textui/Conversations.py | 465 ++++++++++++++++++++++------ nomadnet/ui/textui/Main.py | 7 +- 3 files changed, 379 insertions(+), 107 deletions(-) diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index 246d9bd..1d13d1e 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -21,11 +21,19 @@ THEMES = { ('menubar', 'black', 'light gray', 'standout', '#111', '#bbb'), ('shortcutbar', 'black', 'light gray', 'standout', '#111', '#bbb'), ('body_text', 'white', 'default', 'default', '#0a0', 'default'), + ('error_text', 'dark red', 'default', 'default', 'dark red', 'default'), ('buttons', 'light green,bold', 'default', 'default', '#00a533', 'default'), ('msg_editor', 'black', 'light cyan', 'standout', '#111', '#0bb'), - ("msg_header_ok", 'black', 'light green', 'standout', 'black', '#6b2',), - ("msg_header_caution", 'black', 'yellow', 'standout', 'black', '#fd3',), - ("list_focus", "black", "light cyan", "standout", "#111", "#0bb"), + ("msg_header_ok", 'black', 'light green', 'standout', '#111', '#6b2'), + ("msg_header_caution", 'black', 'yellow', 'standout', '#111', '#fd3'), + ("msg_header_sent", 'black', 'light gray', 'standout', '#111', '#ddd'), + ("msg_header_delivered", 'black', 'light blue', 'standout', '#111', '#28b'), + ("msg_warning_untrusted", 'black', 'dark red', 'standout', 'black', 'dark red'), + ("list_focus", "black", "light gray", "standout", "#111", "#bbb"), + ("list_off_focus", "black", "dark gray", "standout", "#111", "dark gray"), + ("list_trusted", "light green", "default", "default", "#6b2", "default"), + ("list_unknown", "dark gray", "default", "default", "light gray", "default"), + ("list_untrusted", "dark red", "default", "default", "dark red", "default"), ] } diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 993b398..a22c381 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -1,9 +1,263 @@ import RNS import time import nomadnet +import LXMF import urwid +from datetime import datetime +from nomadnet.Directory import DirectoryEntry + +class ConversationListDisplayShortcuts(): + def __init__(self, app): + self.app = app + + self.widget = urwid.AttrMap(urwid.Text("[Enter] Open [C-a] Add to directory [C-x] Delete [C-n] New"), "shortcutbar") + +class ConversationDisplayShortcuts(): + def __init__(self, app): + self.app = app + + self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-k] Clear [C-t] Add Title [C-w] Close Conversation"), "shortcutbar") + +class ConversationsArea(urwid.LineBox): + def keypress(self, size, key): + if key == "ctrl a": + self.delegate.add_selected_to_directory() + elif key == "ctrl x": + self.delegate.delete_selected_conversation() + elif key == "ctrl n": + self.delegate.new_conversation() + else: + return super(ConversationsArea, self).keypress(size, key) + +class ConversationsDisplay(): + list_width = 0.33 + cached_conversation_widgets = {} + + def __init__(self, app): + self.app = app + self.dialog_open = False + + def disp_list_shortcuts(sender, arg1, arg2): + self.shortcuts_display = self.list_shortcuts + self.app.ui.main_display.update_active_shortcuts() + + self.update_listbox() + + self.columns_widget = urwid.Columns([("weight", ConversationsDisplay.list_width, self.listbox), ("weight", 1-ConversationsDisplay.list_width, 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 = self.columns_widget + nomadnet.Conversation.created_callback = self.update_conversation_list + + def focus_change_event(self): + # This hack corrects buggy styling behaviour in IndicativeListBox + if not self.dialog_open: + ilb_position = self.ilb.get_selected_position() + self.update_conversation_list() + self.ilb.select_item(ilb_position) + + def update_listbox(self): + + from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox + + conversation_list_widgets = [] + for conversation in self.app.conversations(): + conversation_list_widgets.append(self.conversation_list_widget(conversation)) + + walker = urwid.SimpleFocusListWalker(conversation_list_widgets) + self.list_widgets = conversation_list_widgets + self.ilb = IndicativeListBox( + self.list_widgets, + on_selection_change=self.conversation_list_selection, + initialization_is_selection_change=False, + highlight_offFocus="list_off_focus" + ) + + self.listbox = ConversationsArea(urwid.Filler(self.ilb, height=("relative", 100))) + self.listbox.delegate = self + + def delete_selected_conversation(self): + self.dialog_open = True + source_hash = self.ilb.get_selected_item().source_hash + + def dismiss_dialog(sender): + self.update_conversation_list() + self.dialog_open = False + + 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 = urwid.LineBox( + urwid.Pile([ + urwid.Text("Delete conversation with\n"+self.app.directory.simplest_display_str(bytes.fromhex(source_hash))+"\n", align="center"), + urwid.Columns([("weight", 0.45, urwid.Button("Yes", on_press=confirmed)), ("weight", 0.1, urwid.Text("")), ("weight", 0.45, urwid.Button("No", on_press=dismiss_dialog))]) + ]), title="?" + ) + bottom = self.listbox + + overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) + + options = self.columns_widget.options("weight", ConversationsDisplay.list_width) + self.columns_widget.contents[0] = (overlay, options) + + def add_selected_to_directory(self): + self.dialog_open = True + source_hash = self.ilb.get_selected_item().source_hash + display_name = self.ilb.get_selected_item().display_name + + e_id = urwid.Edit(caption="ID : ",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.update_conversation_list() + self.dialog_open = False + + 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 + + entry = DirectoryEntry(source_hash, display_name, trust_level) + self.app.directory.remember(entry) + self.update_conversation_list() + self.dialog_open = False + 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="pack") + dialog_pile.contents.append((urwid.Text(""), options)) + dialog_pile.contents.append((urwid.Text(("error_text", "Could not save entry. Check your input."), align="center"), options)) + + dialog_pile = urwid.Pile([ + e_id, + e_name, + urwid.Text(""), + r_untrusted, + r_unknown, + r_trusted, + urwid.Text(""), + urwid.Columns([("weight", 0.45, urwid.Button("Add", on_press=confirmed)), ("weight", 0.1, urwid.Text("")), ("weight", 0.45, urwid.Button("Cancel", on_press=dismiss_dialog))]) + ]) + dialog_pile.error_display = False + + dialog = urwid.LineBox(dialog_pile, title="Add to Directory") + bottom = self.listbox + + overlay = urwid.Overlay(dialog, bottom, align="center", width=("relative", 100), valign="middle", height="pack", left=2, right=2) + + options = self.columns_widget.options("weight", ConversationsDisplay.list_width) + self.columns_widget.contents[0] = (overlay, options) + + def new_conversation(self): + pass + + 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 conversation_list_selection(self, arg1, arg2): + pass + + def update_conversation_list(self): + ilb_position = self.ilb.get_selected_position() + self.update_listbox() + options = self.columns_widget.options("weight", ConversationsDisplay.list_width) + self.columns_widget.contents[0] = (self.listbox, options) + self.ilb.select_item(ilb_position) + nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.draw_screen() + + + + + def display_conversation(self, sender=None, source_hash=None): + self.currently_displayed_conversation = source_hash + options = self.widget.options("weight", 1-ConversationsDisplay.list_width) + self.widget.contents[1] = (self.make_conversation_widget(source_hash), options) + + + 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) + return conversation_widget + else: + widget = ConversationWidget(source_hash) + widget.delegate = self + ConversationsDisplay.cached_conversation_widgets[source_hash] = widget + return widget + + def close_conversation(self, conversation): + ConversationsDisplay.cached_conversation_widgets.pop(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] + + if trust_level == DirectoryEntry.UNTRUSTED: + symbol = "x" + style = "list_untrusted" + elif trust_level == DirectoryEntry.UNKNOWN: + symbol = "?" + style = "list_unknown" + elif trust_level == DirectoryEntry.TRUSTED: + symbol = "\u2713" + style = "list_trusted" + elif trust_level == DirectoryEntry.WARNING: + symbol = "\u26A0" + style = "list_warning" + else: + symbol = "\u26A0" + style = "list_warning" + + display_text = symbol + if display_name != None: + display_text += " "+display_name + + if trust_level != DirectoryEntry.TRUSTED: + display_text += " <"+source_hash+">" + + widget = ListEntry(display_text) + urwid.connect_signal(widget, "click", self.display_conversation, conversation[0]) + display_widget = urwid.AttrMap(widget, style, "list_focus") + display_widget.source_hash = source_hash + display_widget.display_name = display_name + + return display_widget + + + def shortcuts(self): + focus_path = self.widget.get_focus_path() + 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 @@ -28,124 +282,131 @@ class ListEntry(urwid.Text): 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 k": + self.delegate.clear_editor() + elif key == "ctrl w": + self.delegate.close() + else: + return super(MessageEdit, self).keypress(size, key) -class ConversationListDisplayShortcuts(): - def __init__(self, app): - self.app = app - - self.widget = urwid.AttrMap(urwid.Text("Conversation List Display Shortcuts"), "shortcutbar") - -class ConversationDisplayShortcuts(): - def __init__(self, app): - self.app = app - - self.widget = urwid.AttrMap(urwid.Text("[C-s] Send"), "shortcutbar") - -class ConversationsDisplay(): - list_width = 0.33 - cached_conversation_widgets = {} - - def __init__(self, app): - from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox - - self.app = app - - conversation_list_widgets = [] - for conversation in app.conversations(): - conversation_list_widgets.append(self.conversation_list_widget(conversation)) - - def disp_list_shortcuts(sender, arg1, arg2): - self.shortcuts_display = self.list_shortcuts - self.app.ui.main_display.update_active_shortcuts() - RNS.log("Modified") - - walker = urwid.SimpleFocusListWalker(conversation_list_widgets) - ilb = IndicativeListBox(conversation_list_widgets) - listbox = urwid.LineBox(urwid.Filler(ilb, height=("relative", 100))) - - columns_widget = urwid.Columns([("weight", ConversationsDisplay.list_width, listbox), ("weight", 1-ConversationsDisplay.list_width, 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 = columns_widget - - def display_conversation(self, sender=None, source_hash=None): - options = self.widget.options("weight", 1-ConversationsDisplay.list_width) - self.widget.contents[1] = (self.make_conversation_widget(source_hash), options) - - - def make_conversation_widget(self, source_hash): - time_format = self.app.time_format - class LXMessageWidget(urwid.WidgetWrap): - def __init__(self, message): - title_string = time.strftime(time_format) - if message.get_title() != "": - title_string += " | " + message.get_title() - if message.signature_validated(): - header_style = "msg_header_ok" - else: - header_style = "msg_header_caution" - title_string = "\u26A0 "+message.get_signature_description() + "\n" + title_string - - title = urwid.AttrMap(urwid.Text(title_string), header_style) - - display_widget = urwid.Pile([ - title, - urwid.Text(message.get_content()), - urwid.Text("") - ]) - - urwid.WidgetWrap.__init__(self, display_widget) +class ConversationWidget(urwid.WidgetWrap): + def __init__(self, source_hash): if source_hash == None: - return urwid.LineBox(urwid.Filler(urwid.Text("No conversation selected"), "top")) + display_widget = urwid.LineBox(urwid.Filler(urwid.Text("No conversation selected"), "top")) + urwid.WidgetWrap.__init__(self, display_widget) else: if source_hash in ConversationsDisplay.cached_conversation_widgets: return ConversationsDisplay.cached_conversation_widgets[source_hash] else: - conversation = nomadnet.Conversation(source_hash, self.app) - message_widgets = [] + self.source_hash = source_hash + self.conversation = nomadnet.Conversation(source_hash, nomadnet.NomadNetworkApp.get_shared_instance()) + self.message_widgets = [] + self.updating_message_widgets = False - for message in conversation.messages: - message_widget = LXMessageWidget(message) - message_widgets.append(message_widget) + self.update_message_widgets() + self.conversation.register_changed_callback(self.conversation_changed) - from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox - messagelist = IndicativeListBox(message_widgets) - msg_editor = urwid.Edit(caption="\u270E", edit_text="", multiline=True) + msg_editor = MessageEdit(caption="\u270E", edit_text="", multiline=True) + msg_editor.delegate = self - widget = urwid.LineBox( - urwid.Frame( - messagelist, - footer=urwid.AttrMap(msg_editor, "msg_editor") - ) + header = None + if self.conversation.trust_level == DirectoryEntry.UNTRUSTED: + header = urwid.AttrMap(urwid.Padding(urwid.Text("\u26A0 Warning: Conversation with untrusted peer \u26A0", align="center")), "msg_warning_untrusted") + + self.editor = msg_editor + + self.frame = urwid.Frame( + self.messagelist, + header=header, + footer=urwid.AttrMap(msg_editor, "msg_editor") ) - def disp_editor_shortcuts(sender, arg1, arg2): - self.shortcuts_display = self.editor_shortcuts - self.app.ui.main_display.update_active_shortcuts() - - urwid.connect_signal(msg_editor, "change", disp_editor_shortcuts, "modified event") + self.display_widget = urwid.LineBox( + self.frame + ) - ConversationsDisplay.cached_conversation_widgets[source_hash] = widget - return widget + urwid.WidgetWrap.__init__(self, self.display_widget) + + def conversation_changed(self, conversation): + self.update_message_widgets(replace = True) + + def update_message_widgets(self, replace = False): + while self.updating_message_widgets: + time.sleep(0.5) + + self.updating_message_widgets = True + self.message_widgets = [] + added_hashes = [] + for message in self.conversation.messages: + message_hash = message.get_hash() + if not message_hash in added_hashes: + added_hashes.append(message_hash) + message_widget = LXMessageWidget(message) + self.message_widgets.append(message_widget) + + self.message_widgets.sort(key=lambda m: m.timestamp, reverse=False) + + from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox + self.messagelist = IndicativeListBox(self.message_widgets, position = len(self.message_widgets)-1) + + if replace: + self.frame.contents["body"] = (self.messagelist, None) + nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.draw_screen() + + self.updating_message_widgets = False - def conversation_list_widget(self, conversation): - #widget = urwid.SelectableIcon(str(conversation), cursor_position=-1) - widget = ListEntry(str(conversation)) - urwid.connect_signal(widget, "click", self.display_conversation, conversation) - return urwid.AttrMap(widget, None, "list_focus") + def clear_editor(self): + self.editor.set_edit_text("") + + def send_message(self): + content = self.editor.get_edit_text() + if not content == "": + self.conversation.send(content) + self.clear_editor() + + def close(self): + self.delegate.close_conversation(self) - def shortcuts(self): - focus_path = self.widget.get_focus_path() - if focus_path[0] == 0: - return self.list_shortcuts - elif focus_path[0] == 1: - return self.editor_shortcuts +class LXMessageWidget(urwid.WidgetWrap): + def __init__(self, message): + app = nomadnet.NomadNetworkApp.get_shared_instance() + self.timestamp = message.get_timestamp() + time_format = app.time_format + message_time = datetime.fromtimestamp(self.timestamp) + title_string = message_time.strftime(time_format) + + if app.lxmf_destination.hash == message.lxm.source_hash: + if message.lxm.state == LXMF.LXMessage.DELIVERED: + header_style = "msg_header_delivered" + title_string = "\u2713 " + title_string + else: + header_style = "msg_header_sent" + title_string = "\u2192 " + title_string else: - return self.list_shortcuts \ No newline at end of file + if message.signature_validated(): + header_style = "msg_header_ok" + title_string = "\u2713 " + title_string + else: + header_style = "msg_header_caution" + title_string = "\u26A0 "+message.get_signature_description() + "\n " + title_string + + if message.get_title() != "": + title_string += " | " + message.get_title() + + title = urwid.AttrMap(urwid.Text(title_string), header_style) + + display_widget = urwid.Pile([ + title, + urwid.Text(message.get_content()), + urwid.Text("") + ]) + + urwid.WidgetWrap.__init__(self, display_widget) \ No newline at end of file diff --git a/nomadnet/ui/textui/Main.py b/nomadnet/ui/textui/Main.py index e641877..6e483f8 100644 --- a/nomadnet/ui/textui/Main.py +++ b/nomadnet/ui/textui/Main.py @@ -48,12 +48,15 @@ class MainFrame(urwid.Frame): def focus_changed(self): current_focus = self.delegate.widget.get_focus_widgets()[-1] current_focus_path = self.delegate.widget.get_focus_path() - RNS.log("Focus changed to: "+str(current_focus_path)) - + if len(current_focus_path) > 1: if current_focus_path[0] == "body": self.delegate.update_active_shortcuts() + if self.delegate.sub_displays.active() == self.delegate.sub_displays.conversations_display: + # Needed to refresh indicativelistbox styles on mouse focus change + self.delegate.sub_displays.conversations_display.focus_change_event() + def mouse_event(self, size, event, button, col, row, focus): current_focus = self.delegate.widget.get_focus_widgets()[-1] if current_focus != self.current_focus: