From 7efc1a640d908e9b316c11a88b726a00019c5675 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Sat, 18 Jul 2026 22:21:55 -0700 Subject: [PATCH] Enhance messaging features with reply support and message selection preservation - Updated `send_message` to include reply ID and context for native replies. - Modified `onAckNak` to correctly update the database with the message context. - Implemented reply prefix formatting in `build_reply_prefix` for better message clarity. - Added functionality to preserve message selection when new messages are received. - Enhanced database handling to store packet IDs and reply IDs for messages. - Updated UI state to manage reply context and message highlighting effectively. - Improved tests to cover new reply features and message selection behavior. --- contact/localisations/en.ini | 3 + contact/message_handlers/bot_handler.py | 2 +- contact/message_handlers/rx_handler.py | 25 ++- contact/message_handlers/tx_handler.py | 39 ++-- contact/ui/contact_ui.py | 242 ++++++++++++++++++++---- contact/ui/ui_state.py | 7 + contact/utilities/db_handler.py | 72 +++++-- contact/utilities/utils.py | 32 +++- tests/test_contact_ui.py | 146 ++++++++++++++ tests/test_db_handler.py | 32 ++++ tests/test_rx_handler.py | 69 ++++++- tests/test_tx_handler.py | 21 +- 12 files changed, 609 insertions(+), 81 deletions(-) diff --git a/contact/localisations/en.ini b/contact/localisations/en.ini index f9663e9..b02cd30 100644 --- a/contact/localisations/en.ini +++ b/contact/localisations/en.ini @@ -67,6 +67,8 @@ dialog.traceroute_not_sent_body, "Please wait {seconds} seconds before sending a dialog.traceroute_sent_title, "Traceroute Sent To: {name}", "" dialog.traceroute_sent_body, "Results will appear in messages window.", "" dialog.help_title, "Help - Shortcut Keys", "" +dialog.reply_unavailable_title, "Reply unavailable", "" +dialog.reply_unavailable_body, "This message has no packet ID, so Contact cannot send a native Meshtastic reply.", "" help.scroll, "Up/Down = Scroll", "" help.switch_window, "Left/Right = Switch window", "" help.jump_windows, "F1/F2/F3 = Jump to Channel/Messages/Nodes", "" @@ -74,6 +76,7 @@ help.enter, "ENTER = Send / Select", "" help.settings, "` or F12 = Settings", "" help.quit, "ESC = Quit", "" help.packet_log, "Ctrl+P = Toggle Packet Log", "" +help.reply, "Ctrl+R = Reply to message at cursor", "" help.traceroute, "Ctrl+T or F4 = Traceroute", "" help.node_info, "F5 = Full node info", "" help.archive_chat, "Ctrl+D = Archive chat / remove node", "" diff --git a/contact/message_handlers/bot_handler.py b/contact/message_handlers/bot_handler.py index e2f193f..374d2a3 100644 --- a/contact/message_handlers/bot_handler.py +++ b/contact/message_handlers/bot_handler.py @@ -40,7 +40,7 @@ def bot_respond(packet: Dict[str, Any], message: str, send_channel: int) -> bool return False snr = packet.get('rxSnr', -128) rssi = packet.get('rxRssi', -128) - replyIDset = packet.get('replyId', False) + replyIDset = (packet.get('decoded') or {}).get('replyId', False) hop_start = packet.get('hopStart', 0) hop_limit = packet.get('hopLimit', 0) transport_type = packet.get('transportMechanism', None) diff --git a/contact/message_handlers/rx_handler.py b/contact/message_handlers/rx_handler.py index 5094852..44c8b27 100644 --- a/contact/message_handlers/rx_handler.py +++ b/contact/message_handlers/rx_handler.py @@ -46,6 +46,7 @@ def schedule_notification_sound(delay: float = _SOUND_DEBOUNCE_SECONDS) -> None: from contact.utilities.utils import ( refresh_node_list, add_new_message, + get_reply_context, ) from contact.ui.contact_ui import ( add_notification, @@ -183,14 +184,32 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None: message_from_id = packet["from"] message_from_string = get_name_from_database(message_from_id, type="short") + ":" - add_new_message(channel_id, f"{config.message_prefix} [{hops}] {message_from_string} ", message_string) + # replyId is a field of Meshtastic's decrypted Data payload. + reply_id = packet["decoded"].get("replyId") + reply_context = get_reply_context(reply_id) if reply_id is not None else "" + add_new_message( + channel_id, + f"{config.message_prefix} [{hops}] {message_from_string} ", + f"{reply_context}{message_string}", + packet_id=packet.get("id"), + ) if refresh_channels: request_ui_redraw(channels=True) if refresh_messages: - request_ui_redraw(messages=True, scroll_messages_to_bottom=True) + request_ui_redraw( + messages=True, + scroll_messages_to_bottom=True, + preserve_message_selection=(ui_state.current_window == 1), + ) - save_message_to_db(channel_id, message_from_id, message_string) + save_message_to_db( + channel_id, + message_from_id, + message_string, + packet_id=packet.get("id"), + reply_id=reply_id, + ) except KeyError as e: logging.error(f"Error processing packet: {e}") diff --git a/contact/message_handlers/tx_handler.py b/contact/message_handlers/tx_handler.py index fb361c9..ac3207b 100644 --- a/contact/message_handlers/tx_handler.py +++ b/contact/message_handlers/tx_handler.py @@ -56,7 +56,7 @@ def onAckNak(packet: Dict[str, Any]) -> None: message, ) - update_ack_nak(acknak["channel"], acknak["timestamp"], message, ack_type) + update_ack_nak(acknak["channel"], acknak["timestamp"], acknak["dbMessage"], ack_type) channel_number = ui_state.channel_list.index(acknak["channel"]) if ui_state.channel_list[channel_number] == ui_state.channel_list[ui_state.selected_channel]: @@ -164,7 +164,13 @@ def on_response_traceroute(packet: Dict[str, Any]) -> None: save_message_to_db(channel_id, packet["from"], msg_str) -def send_message(message: str, destination: int = BROADCAST_NUM, channel: int = 0) -> None: +def send_message( + message: str, + destination: int = BROADCAST_NUM, + channel: int = 0, + reply_id: int | None = None, + reply_context: str = "", +) -> None: """ Sends a chat message using the selected channel. """ @@ -177,23 +183,32 @@ def send_message(message: str, destination: int = BROADCAST_NUM, channel: int = elif isinstance(channel_id, str): send_on_channel = channel - sent_message_data = interface_state.interface.sendText( - text=message, - destinationId=destination, - wantAck=True, - wantResponse=False, - onResponse=onAckNak, - channelIndex=send_on_channel, + send_kwargs = { + "text": message, + "destinationId": destination, + "wantAck": True, + "wantResponse": False, + "onResponse": onAckNak, + "channelIndex": send_on_channel, + } + if reply_id is not None: + send_kwargs["replyId"] = reply_id + sent_message_data = interface_state.interface.sendText(**send_kwargs) + + add_new_message( + channel_id, + config.sent_message_prefix + config.ack_unknown_str + ": ", + f"{reply_context}{message}", + packet_id=sent_message_data.id, ) - add_new_message(channel_id, config.sent_message_prefix + config.ack_unknown_str + ": ", message) - - timestamp = save_message_to_db(channel_id, myid, message) + timestamp = save_message_to_db(channel_id, myid, message, packet_id=sent_message_data.id, reply_id=reply_id) ack_naks[sent_message_data.id] = { "channel": channel_id, "messageIndex": len(ui_state.all_messages[channel_id]) - 1, "timestamp": timestamp, + "dbMessage": message, } diff --git a/contact/ui/contact_ui.py b/contact/ui/contact_ui.py index cdea4f9..29bbe4a 100644 --- a/contact/ui/contact_ui.py +++ b/contact/ui/contact_ui.py @@ -5,7 +5,14 @@ import traceback from numbers import Real from typing import Union -from contact.utilities.utils import get_channels, get_readable_duration, get_time_ago, refresh_node_list, add_new_message +from contact.utilities.utils import ( + get_channels, + get_readable_duration, + get_time_ago, + refresh_node_list, + add_new_message, + build_reply_prefix, +) from contact.settings import settings_menu from contact.message_handlers.tx_handler import send_message, send_traceroute from contact.utilities.utils import parse_protobuf @@ -41,6 +48,7 @@ def request_ui_redraw( packetlog: bool = False, full: bool = False, scroll_messages_to_bottom: bool = False, + preserve_message_selection: bool = False, ) -> None: ui_state.redraw_channels = ui_state.redraw_channels or channels ui_state.redraw_messages = ui_state.redraw_messages or messages @@ -48,6 +56,7 @@ def request_ui_redraw( ui_state.redraw_packetlog = ui_state.redraw_packetlog or packetlog ui_state.redraw_full_ui = ui_state.redraw_full_ui or full ui_state.scroll_messages_to_bottom = ui_state.scroll_messages_to_bottom or scroll_messages_to_bottom + ui_state.preserve_message_selection = ui_state.preserve_message_selection or preserve_message_selection def process_pending_ui_updates(stdscr: curses.window) -> None: @@ -58,6 +67,7 @@ def process_pending_ui_updates(stdscr: curses.window) -> None: ui_state.redraw_nodes = False ui_state.redraw_packetlog = False ui_state.scroll_messages_to_bottom = False + ui_state.preserve_message_selection = False handle_resize(stdscr, False) return @@ -71,9 +81,14 @@ def process_pending_ui_updates(stdscr: curses.window) -> None: if ui_state.redraw_messages: scroll_to_bottom = ui_state.scroll_messages_to_bottom + preserve_selection = ui_state.preserve_message_selection ui_state.redraw_messages = False ui_state.scroll_messages_to_bottom = False - draw_messages_window(scroll_to_bottom) + ui_state.preserve_message_selection = False + if preserve_selection: + draw_messages_window(scroll_to_bottom, preserve_selection=True) + else: + draw_messages_window(scroll_to_bottom) if ui_state.redraw_packetlog: ui_state.redraw_packetlog = False @@ -370,7 +385,8 @@ def main_ui(stdscr: curses.window) -> None: while True: with app_state.lock: process_pending_ui_updates(stdscr) - draw_text_field(entry_win, f"Message: {(input_text or '')[-(stdscr.getmaxyx()[1] - 10):]}", get_color("input")) + entry_display = f"{ui_state.reply_context}{input_text or ''}" + draw_text_field(entry_win, f"Message: {entry_display[-(stdscr.getmaxyx()[1] - 10):]}", get_color("input")) # Get user input from entry window try: @@ -426,6 +442,12 @@ def main_ui(stdscr: curses.window) -> None: elif char == chr(16): # Ctrl + P for Packet Log handle_ctrl_p() + elif char == chr(18): # Ctrl + R for Reply + cancelling_reply = bool(ui_state.reply_context) + input_text = handle_ctrl_r(input_text) + if cancelling_reply: + entry_win.erase() + elif char == curses.KEY_RESIZE: input_text = "" queued_char = drain_resize_events(entry_win) @@ -488,7 +510,8 @@ def handle_home() -> None: if ui_state.current_window == 0: select_channel(0) elif ui_state.current_window == 1: - ui_state.selected_message = 0 + set_message_selection(0) + refresh_message_highlight() refresh_pad(1) elif ui_state.current_window == 2: select_node(0) @@ -501,8 +524,8 @@ def handle_end() -> None: if ui_state.current_window == 0: select_channel(len(ui_state.channel_list) - 1) elif ui_state.current_window == 1: - msg_line_count = messages_pad.getmaxyx()[0] - ui_state.selected_message = max(msg_line_count - get_msg_window_lines(messages_win, packetlog_win), 0) + set_message_selection(messages_pad.getmaxyx()[0] - 1) + refresh_message_highlight() refresh_pad(1) elif ui_state.current_window == 2: select_node(len(ui_state.node_list) - 1) @@ -514,9 +537,8 @@ def handle_pageup() -> None: if ui_state.current_window == 0: select_channel(ui_state.selected_channel - (channel_win.getmaxyx()[0] - 2)) elif ui_state.current_window == 1: - ui_state.selected_message = max( - ui_state.selected_message - get_msg_window_lines(messages_win, packetlog_win), 0 - ) + set_message_selection(ui_state.selected_message - get_msg_window_lines(messages_win, packetlog_win)) + refresh_message_highlight() refresh_pad(1) elif ui_state.current_window == 2: select_node(ui_state.selected_node - (nodes_win.getmaxyx()[0] - 2)) @@ -528,11 +550,8 @@ def handle_pagedown() -> None: if ui_state.current_window == 0: select_channel(ui_state.selected_channel + (channel_win.getmaxyx()[0] - 2)) elif ui_state.current_window == 1: - msg_line_count = messages_pad.getmaxyx()[0] - ui_state.selected_message = min( - ui_state.selected_message + get_msg_window_lines(messages_win, packetlog_win), - msg_line_count - get_msg_window_lines(messages_win, packetlog_win), - ) + set_message_selection(ui_state.selected_message + get_msg_window_lines(messages_win, packetlog_win)) + refresh_message_highlight() refresh_pad(1) elif ui_state.current_window == 2: select_node(ui_state.selected_node + (nodes_win.getmaxyx()[0] - 2)) @@ -555,6 +574,9 @@ def handle_leftright(char: int) -> None: refresh_main_window(ui_state.current_window, selected=True) draw_window_arrows(ui_state.current_window) + if ui_state.current_window == 1: + draw_messages_window(True) + refresh_message_highlight() def handle_function_keys(char: int) -> None: @@ -585,6 +607,9 @@ def handle_function_keys(char: int) -> None: refresh_main_window(ui_state.current_window, selected=True) draw_window_arrows(ui_state.current_window) + if ui_state.current_window == 1: + draw_messages_window(True) + refresh_message_highlight() def handle_enter(input_text: str) -> str: @@ -612,6 +637,15 @@ def handle_enter(input_text: str) -> str: return input_text elif len(input_text) > 0: + if ui_state.reply_context and ui_state.reply_id is None: + contact.ui.dialog.dialog( + t("ui.dialog.reply_unavailable_title", default="Reply unavailable"), + t( + "ui.dialog.reply_unavailable_body", + default="This message has no packet ID, so Contact cannot send a native Meshtastic reply.", + ), + ) + return input_text # TODO: This is a hack to prevent sending messages too quickly. Let's get errors from the node. now = time.monotonic() if now - ui_state.last_sent_time < 2.5: @@ -621,7 +655,15 @@ def handle_enter(input_text: str) -> str: ) return input_text # Enter key pressed, send user input as message - send_message(input_text, channel=ui_state.selected_channel) + send_message( + input_text, + channel=ui_state.selected_channel, + reply_id=ui_state.reply_id, + reply_context=ui_state.reply_context, + ) + ui_state.reply_id = None + ui_state.reply_context = "" + ui_state.reply_id_unavailable = False draw_messages_window(True) ui_state.last_sent_time = now entry_win.erase() @@ -920,6 +962,49 @@ def handle_ctrl_p() -> None: draw_messages_window(True) +def get_message_at_display_line(channel, display_line: int): + """Return the message containing a rendered line in a channel, if any.""" + line = 0 + for prefix, message in ui_state.all_messages.get(channel, []): + wrapped_lines = wrap_text( + normalize_message_text(f"{prefix}{message}"), + messages_win.getmaxyx()[1] - 2, + ) + if line <= display_line < line + len(wrapped_lines): + return None if prefix.startswith("--") else (prefix, message) + line += len(wrapped_lines) + return None + + +def handle_ctrl_r(input_text: str) -> str: + """Toggle a reply to the message at the current Messages-pane position.""" + if ui_state.reply_context: + ui_state.reply_id = None + ui_state.reply_context = "" + ui_state.reply_id_unavailable = False + return "" + + if ui_state.current_window != 1 or not ui_state.channel_list: + return input_text + + channel = ui_state.channel_list[ui_state.selected_channel] + selected = get_message_at_display_line(channel, ui_state.selected_message) + if selected is None: + return input_text + + message_index = next( + index + for index, entry in enumerate(ui_state.all_messages[channel]) + if entry == selected + ) + packet_ids = ui_state.message_packet_ids.get(channel, []) + prefix, message = selected + ui_state.reply_context = build_reply_prefix(prefix, message) + ui_state.reply_id = packet_ids[message_index] if message_index < len(packet_ids) else None + ui_state.reply_id_unavailable = ui_state.reply_id is None + return input_text + + # --- Ctrl+K handler for Help --- def handle_ctrl_k(stdscr: curses.window) -> None: """Handle Ctrl + K to show a help window with shortcut keys.""" @@ -933,6 +1018,7 @@ def handle_ctrl_k(stdscr: curses.window) -> None: t("ui.help.settings", default="` or F12 = Settings"), t("ui.help.quit", default="ESC = Quit"), t("ui.help.packet_log", default="Ctrl+P = Toggle Packet Log"), + t("ui.help.reply", default="Ctrl+R = Reply to message at cursor"), t("ui.help.traceroute", default="Ctrl+T or F4 = Traceroute"), t("ui.help.node_info", default="F5 = Full node info"), t("ui.help.archive_chat", default="Ctrl+D = Archive chat / remove node"), @@ -1152,7 +1238,7 @@ def draw_channel_list() -> None: channel_win.refresh() -def draw_messages_window(scroll_to_bottom: bool = False) -> None: +def draw_messages_window(scroll_to_bottom: bool = False, preserve_selection: bool = False) -> None: """Update the messages window based on the selected channel and scroll position.""" if ui_state.current_window != 1 and ui_state.single_pane_mode: @@ -1163,10 +1249,12 @@ def draw_messages_window(scroll_to_bottom: bool = False) -> None: channel = ui_state.channel_list[ui_state.selected_channel] msg_line_count = 0 + message_ranges = [] if channel in ui_state.all_messages: messages = ui_state.all_messages[channel] rendered_lines = [] for prefix, message in messages: + start_line = len(rendered_lines) full_message = normalize_message_text(f"{prefix}{message}") wrapped_lines = wrap_text(full_message, messages_win.getmaxyx()[1] - 2) for line in wrapped_lines: @@ -1179,20 +1267,29 @@ def draw_messages_window(scroll_to_bottom: bool = False) -> None: rendered_lines.append((line, color)) + if not prefix.startswith("--"): + message_ranges.append((start_line, len(rendered_lines), color)) + msg_line_count = len(rendered_lines) messages_pad.resize(max(1, msg_line_count), messages_win.getmaxyx()[1]) for row, (line, color) in enumerate(rendered_lines): messages_pad.addstr(row, 1, line, color) + ui_state.message_line_ranges[channel] = message_ranges paint_frame(messages_win, selected=(ui_state.current_window == 1)) visible_lines = get_msg_window_lines(messages_win, packetlog_win) if scroll_to_bottom: - ui_state.selected_message = max(msg_line_count - visible_lines, 0) - ui_state.start_index[1] = max(msg_line_count - visible_lines, 0) + if preserve_selection: + set_message_selection(ui_state.selected_message) + ui_state.start_index[1] = max(0, msg_line_count - visible_lines) + else: + set_message_selection(msg_line_count - 1) else: - ui_state.selected_message = max(min(ui_state.selected_message, msg_line_count - visible_lines), 0) + set_message_selection(ui_state.selected_message) + + refresh_message_highlight() messages_win.refresh() refresh_pad(1) @@ -1203,6 +1300,39 @@ def draw_messages_window(scroll_to_bottom: bool = False) -> None: menu_state.need_redraw = True +def refresh_message_highlight() -> None: + """Apply a reverse-video highlight to the message at the current scroll position.""" + previous_range = ui_state.highlighted_message_range + width = max(0, messages_win.getmaxyx()[1] - 2) + + if not ui_state.channel_list or ui_state.current_window != 1: + if previous_range: + start, end, color = previous_range + for row in range(start, end): + messages_pad.chgat(row, 1, width, color) + ui_state.highlighted_message_range = () + return + + channel = ui_state.channel_list[ui_state.selected_channel] + ranges = ui_state.message_line_ranges.get(channel, []) + selected_range = next( + (line_range for line_range in ranges if line_range[0] <= ui_state.selected_message < line_range[1]), + None, + ) + + if previous_range and previous_range != selected_range: + start, end, color = previous_range + for row in range(start, end): + messages_pad.chgat(row, 1, width, color) + + if selected_range: + start, end, color = selected_range + for row in range(start, end): + messages_pad.chgat(row, 1, width, color | curses.A_REVERSE) + + ui_state.highlighted_message_range = selected_range or () + + def draw_node_list() -> None: """Update the nodes list window and pad based on the current state.""" global nodes_pad @@ -1293,31 +1423,67 @@ def scroll_messages(direction: int) -> None: refresh_pad(1) return - ui_state.selected_message += direction - - msg_line_count = messages_pad.getmaxyx()[0] - ui_state.selected_message = max( - 0, min(ui_state.selected_message, msg_line_count - get_msg_window_lines(messages_win, packetlog_win)) - ) - - max_index = msg_line_count - 1 - visible_height = get_msg_window_lines(messages_win, packetlog_win) - - if ui_state.selected_message < ui_state.start_index[ui_state.current_window]: # Moving above the visible area - ui_state.start_index[ui_state.current_window] = ui_state.selected_message - elif ui_state.selected_message >= ui_state.start_index[ui_state.current_window]: # Moving below the visible area - ui_state.start_index[ui_state.current_window] = ui_state.selected_message - - # Ensure start_index is within bounds - ui_state.start_index[ui_state.current_window] = max( - 0, min(ui_state.start_index[ui_state.current_window], max_index - visible_height + 1) - ) + move_message_selection(direction) + refresh_message_highlight() messages_win.refresh() refresh_pad(1) draw_window_arrows(ui_state.current_window) +def set_message_selection(line: int) -> None: + """Select a rendered message line and keep it visible without limiting selection to the viewport.""" + msg_line_count = messages_pad.getmaxyx()[0] + visible_lines = get_msg_window_lines(messages_win, packetlog_win) + ui_state.selected_message = max(0, min(line, max(msg_line_count - 1, 0))) + + max_start = max(msg_line_count - visible_lines, 0) + start = ui_state.start_index[1] + if ui_state.selected_message < start: + start = ui_state.selected_message + elif ui_state.selected_message >= start + visible_lines: + start = ui_state.selected_message - visible_lines + 1 + ui_state.start_index[1] = max(0, min(start, max_start)) + + +def move_message_selection(direction: int) -> None: + """Move between messages, keeping wrapped messages as one selectable item.""" + if not ui_state.channel_list: + return + + channel = ui_state.channel_list[ui_state.selected_channel] + ranges = ui_state.message_line_ranges.get(channel, []) + if not ranges: + set_message_selection(ui_state.selected_message + direction) + return + + current_index = next( + (index for index, line_range in enumerate(ranges) if line_range[0] <= ui_state.selected_message < line_range[1]), + None, + ) + if current_index is None: + current_index = 0 if direction > 0 else len(ranges) - 1 + else: + current_index = max(0, min(current_index + direction, len(ranges) - 1)) + + start, end, _color = ranges[current_index] + set_message_selection(end - 1 if current_index == len(ranges) - 1 else start) + + +def select_last_message() -> None: + """Select and reveal the newest message in the active channel.""" + if not ui_state.channel_list: + return + + channel = ui_state.channel_list[ui_state.selected_channel] + ranges = ui_state.message_line_ranges.get(channel, []) + if ranges: + _start, end, _color = ranges[-1] + set_message_selection(end - 1) + else: + set_message_selection(0) + + def select_node(idx: int) -> None: """Select a node by index and update the UI state accordingly.""" old_selected_node = ui_state.selected_node diff --git a/contact/ui/ui_state.py b/contact/ui/ui_state.py index 394f691..1fa9ee6 100644 --- a/contact/ui/ui_state.py +++ b/contact/ui/ui_state.py @@ -40,8 +40,15 @@ class ChatUIState: redraw_packetlog: bool = False redraw_full_ui: bool = False scroll_messages_to_bottom: bool = False + preserve_message_selection: bool = False oldest_message_rowid: Dict[Union[str, int], int] = field(default_factory=dict) has_older_messages: Dict[Union[str, int], bool] = field(default_factory=dict) + message_line_ranges: Dict[Union[str, int], List[tuple]] = field(default_factory=dict) + highlighted_message_range: tuple = field(default_factory=tuple) + message_packet_ids: Dict[Union[str, int], List[Any]] = field(default_factory=dict) + reply_id: Any = None + reply_context: str = "" + reply_id_unavailable: bool = False @dataclass diff --git a/contact/utilities/db_handler.py b/contact/utilities/db_handler.py index 6adac0e..6ea88c9 100644 --- a/contact/utilities/db_handler.py +++ b/contact/utilities/db_handler.py @@ -4,7 +4,7 @@ import logging from datetime import datetime from typing import Optional, Union, Dict -from contact.utilities.utils import decimal_to_hex +from contact.utilities.utils import build_reply_prefix, decimal_to_hex import contact.ui.default_config as config @@ -21,7 +21,21 @@ def get_table_name(channel: str) -> str: return quoted_table_name -def save_message_to_db(channel: str, user_id: str, message_text: str) -> Optional[int]: +def _ensure_message_columns(db_cursor, quoted_table_name: str) -> None: + """Add fields introduced after the original message-history schema.""" + table_columns = {row[1] for row in db_cursor.execute(f"PRAGMA table_info({quoted_table_name})")} + for column, definition in (("ack_type", "TEXT"), ("packet_id", "INTEGER"), ("reply_id", "INTEGER")): + if column not in table_columns: + db_cursor.execute(f"ALTER TABLE {quoted_table_name} ADD COLUMN {column} {definition}") + + +def save_message_to_db( + channel: str, + user_id: str, + message_text: str, + packet_id: Optional[int] = None, + reply_id: Optional[int] = None, +) -> Optional[int]: """Save messages to the database, ensuring the table exists.""" try: quoted_table_name = get_table_name(channel) @@ -30,21 +44,25 @@ def save_message_to_db(channel: str, user_id: str, message_text: str) -> Optiona user_id TEXT, message_text TEXT, timestamp INTEGER, - ack_type TEXT + ack_type TEXT, + packet_id INTEGER, + reply_id INTEGER """ ensure_table_exists(quoted_table_name, schema) with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection: db_connection.execute("PRAGMA busy_timeout=10000") db_cursor = db_connection.cursor() + _ensure_message_columns(db_cursor, quoted_table_name) timestamp = int(time.time()) # Insert the message insert_query = f""" - INSERT INTO {quoted_table_name} (user_id, message_text, timestamp, ack_type) - VALUES (?, ?, ?, ?) + INSERT INTO {quoted_table_name} + (user_id, message_text, timestamp, ack_type, packet_id, reply_id) + VALUES (?, ?, ?, ?, ?, ?) """ - db_cursor.execute(insert_query, (user_id, message_text, timestamp, None)) + db_cursor.execute(insert_query, (user_id, message_text, timestamp, None, packet_id, reply_id)) db_connection.commit() return timestamp @@ -79,9 +97,10 @@ def update_ack_nak(channel: str, timestamp: int, message: str, ack: str) -> None def _format_db_messages(db_messages, node_names): - """Format database rows, adding one timestamp separator per hour.""" + """Format database rows and packet IDs, adding one timestamp separator per hour.""" hourly_messages = {} - for _rowid, user_id, message, timestamp, ack_type in db_messages: + known_messages = {} + for _rowid, user_id, message, timestamp, ack_type, packet_id, reply_id in db_messages: if user_id is None or message is None or timestamp is None: logging.warning(f"Skipping row with NULL required field(s): {(user_id, message, timestamp, ack_type)}") continue @@ -108,13 +127,24 @@ def _format_db_messages(db_messages, node_names): f"{ts_str} {config.message_prefix} {node_names.get(str(user_id), fallback_name)}: ", sanitized_message, ) - hourly_messages.setdefault(hour, []).append(formatted_message) + + if reply_id is not None and reply_id in known_messages: + referenced_prefix, referenced_message = known_messages[reply_id] + formatted_message = (formatted_message[0], build_reply_prefix(referenced_prefix, referenced_message) + sanitized_message) + + hourly_messages.setdefault(hour, []).append((formatted_message, packet_id)) + if packet_id is not None: + known_messages[packet_id] = formatted_message formatted = [] + packet_ids = [] for hour, messages in sorted(hourly_messages.items()): formatted.append((f"-- {hour} --", "")) - formatted.extend(messages) - return formatted + packet_ids.append(None) + for formatted_message, packet_id in messages: + formatted.append(formatted_message) + packet_ids.append(packet_id) + return formatted, packet_ids def _load_node_names(db_cursor): @@ -142,14 +172,10 @@ def load_messages_from_db(page_size: int = MESSAGE_PAGE_SIZE) -> None: quoted_table_name = ( f'"{table_name}"' # Quote the table name because we begin with numerics and contain spaces ) - table_columns = [i[1] for i in db_cursor.execute(f"PRAGMA table_info({quoted_table_name})")] - if "ack_type" not in table_columns: - update_table_query = f"ALTER TABLE {quoted_table_name} ADD COLUMN ack_type TEXT" - db_cursor.execute(update_table_query) - db_connection.commit() + _ensure_message_columns(db_cursor, quoted_table_name) query = f""" - SELECT rowid, user_id, message_text, timestamp, ack_type + SELECT rowid, user_id, message_text, timestamp, ack_type, packet_id, reply_id FROM {quoted_table_name} ORDER BY rowid DESC LIMIT ? """ @@ -173,7 +199,9 @@ def load_messages_from_db(page_size: int = MESSAGE_PAGE_SIZE) -> None: ui_state.channel_list.append(channel) # Replace the channel's messages with the freshly loaded page to avoid duplicates - ui_state.all_messages[channel] = _format_db_messages(db_messages, node_names) + formatted_messages, packet_ids = _format_db_messages(db_messages, node_names) + ui_state.all_messages[channel] = formatted_messages + ui_state.message_packet_ids[channel] = packet_ids if db_messages: ui_state.oldest_message_rowid[channel] = db_messages[0][0] ui_state.has_older_messages[channel] = has_older @@ -196,7 +224,7 @@ def load_older_messages(channel, page_size: int = MESSAGE_PAGE_SIZE) -> int: db_connection.execute("PRAGMA busy_timeout=10000") db_cursor = db_connection.cursor() query = f""" - SELECT rowid, user_id, message_text, timestamp, ack_type + SELECT rowid, user_id, message_text, timestamp, ack_type, packet_id, reply_id FROM {get_table_name(channel)} WHERE rowid < ? ORDER BY rowid DESC LIMIT ? """ @@ -208,12 +236,16 @@ def load_older_messages(channel, page_size: int = MESSAGE_PAGE_SIZE) -> int: ui_state.has_older_messages[channel] = False return 0 - older = _format_db_messages(db_messages, _load_node_names(db_cursor)) + older, older_packet_ids = _format_db_messages(db_messages, _load_node_names(db_cursor)) current = ui_state.all_messages.setdefault(channel, []) + current_packet_ids = ui_state.message_packet_ids.setdefault(channel, []) + while len(current_packet_ids) < len(current): + current_packet_ids.append(None) # Keep the existing page's leading separator even when the older page # falls in the same hour. Removing it makes a timestamp that the user # is looking at jump out of view as soon as another page is loaded. ui_state.all_messages[channel] = older + current + ui_state.message_packet_ids[channel] = older_packet_ids + current_packet_ids ui_state.oldest_message_rowid[channel] = db_messages[0][0] ui_state.has_older_messages[channel] = has_older return len(db_messages) diff --git a/contact/utilities/utils.py b/contact/utilities/utils.py index 0a998b0..f145e5e 100644 --- a/contact/utilities/utils.py +++ b/contact/utilities/utils.py @@ -1,4 +1,5 @@ import datetime +import re import time from typing import Optional, Union from google.protobuf.message import DecodeError @@ -156,10 +157,37 @@ def get_time_ago(timestamp): return "now" -def add_new_message(channel_id, prefix, message): +REPLY_EXCERPT_LENGTH = 5 + + +def build_reply_prefix(prefix: str, message: str) -> str: + """Create Contact's local display marker for a native Meshtastic reply.""" + sender_text = re.sub(r"^\[[^]]+\]\s*", "", prefix).strip() + sender_text = re.sub(r"^(?:>>|<<)\s*(?:\[[^]]+\]\s*)?", "", sender_text) + sender_match = re.search(r"(.+?)\s*:\s*$", sender_text) + sender = sender_match.group(1).strip() if sender_match else "me" + excerpt = " ".join(message.replace("\x00", "").split())[:REPLY_EXCERPT_LENGTH] + return f" " + + +def get_reply_context(reply_id): + """Find the locally displayed message referred to by a Meshtastic reply ID.""" + for channel, packet_ids in ui_state.message_packet_ids.items(): + for index, packet_id in enumerate(packet_ids): + if packet_id == reply_id and index < len(ui_state.all_messages.get(channel, [])): + prefix, message = ui_state.all_messages[channel][index] + return build_reply_prefix(prefix, message) + return "" + + +def add_new_message(channel_id, prefix, message, packet_id=None): if channel_id not in ui_state.all_messages: ui_state.all_messages[channel_id] = [] + packet_ids = ui_state.message_packet_ids.setdefault(channel_id, []) + while len(packet_ids) < len(ui_state.all_messages[channel_id]): + packet_ids.append(None) + # Timestamp handling current_timestamp = time.time() current_hour = datetime.datetime.fromtimestamp(current_timestamp).strftime("%Y-%m-%d %H:00") @@ -180,10 +208,12 @@ def add_new_message(channel_id, prefix, message): # Add a new timestamp if it's a new hour if last_hour != current_hour: ui_state.all_messages[channel_id].append((f"-- {current_hour} --", "")) + packet_ids.append(None) # Add the message ts_str = time.strftime("[%H:%M:%S] ") ui_state.all_messages[channel_id].append((f"{ts_str}{prefix}", message)) + packet_ids.append(packet_id) def parse_protobuf(packet: dict) -> Union[str, dict]: diff --git a/tests/test_contact_ui.py b/tests/test_contact_ui.py index 0cb0387..3b964b4 100644 --- a/tests/test_contact_ui.py +++ b/tests/test_contact_ui.py @@ -70,10 +70,23 @@ class ContactUiTests(unittest.TestCase): self.assertFalse(ui_state.redraw_channels) self.assertFalse(ui_state.redraw_messages) + def test_process_pending_ui_updates_preserves_selection_when_scrolling_to_new_messages(self) -> None: + stdscr = mock.Mock() + ui_state.redraw_messages = True + ui_state.scroll_messages_to_bottom = True + ui_state.preserve_message_selection = True + + with mock.patch.object(contact_ui, "draw_messages_window") as draw_messages_window: + contact_ui.process_pending_ui_updates(stdscr) + + draw_messages_window.assert_called_once_with(True, preserve_selection=True) + self.assertFalse(ui_state.preserve_message_selection) + def test_draw_messages_resizes_pad_once(self) -> None: ui_state.channel_list = ["Primary"] ui_state.all_messages = {"Primary": [("[10:00] RX: ", "one"), ("[10:01] RX: ", "two")]} contact_ui.messages_pad = mock.Mock() + contact_ui.messages_pad.getmaxyx.return_value = (2, 40) contact_ui.messages_win = mock.Mock() contact_ui.messages_win.getmaxyx.return_value = (10, 40) contact_ui.packetlog_win = mock.Mock() @@ -89,6 +102,139 @@ class ContactUiTests(unittest.TestCase): contact_ui.messages_pad.resize.assert_called_once_with(2, 40) + def test_build_reply_prefix_includes_sender_and_five_character_excerpt(self) -> None: + reply = contact_ui.build_reply_prefix( + "[06:27:25] >> [6] B1G1: ", + "This is a message long enough to be shortened for the reply marker.", + ) + + self.assertEqual(reply, " ") + + def test_handle_ctrl_r_prefills_reply_for_message_at_cursor(self) -> None: + ui_state.current_window = 1 + ui_state.channel_list = ["Primary"] + ui_state.all_messages = {"Primary": [("[06:27:25] >> [6] B1G1: ", "Good morning all.")]} + ui_state.message_packet_ids = {"Primary": [1234]} + ui_state.selected_message = 0 + contact_ui.messages_win = mock.Mock() + contact_ui.messages_win.getmaxyx.return_value = (10, 80) + + self.assertEqual(contact_ui.handle_ctrl_r("Same to you!"), "Same to you!") + self.assertEqual(ui_state.reply_id, 1234) + self.assertEqual(ui_state.reply_context, " ") + + def test_handle_ctrl_r_clears_an_existing_reply(self) -> None: + ui_state.current_window = 1 + ui_state.reply_id = 1234 + ui_state.reply_context = " " + ui_state.reply_id_unavailable = False + + self.assertEqual(contact_ui.handle_ctrl_r("Same to you!"), "") + self.assertIsNone(ui_state.reply_id) + self.assertEqual(ui_state.reply_context, "") + self.assertFalse(ui_state.reply_id_unavailable) + + def test_handle_ctrl_r_shows_context_when_message_id_is_unavailable(self) -> None: + ui_state.current_window = 1 + ui_state.channel_list = ["Primary"] + ui_state.all_messages = {"Primary": [("[06:27:25] >> [6] B1G1: ", "Good morning all.")]} + ui_state.selected_message = 0 + contact_ui.messages_win = mock.Mock() + contact_ui.messages_win.getmaxyx.return_value = (10, 80) + + self.assertEqual(contact_ui.handle_ctrl_r(""), "") + self.assertEqual(ui_state.reply_context, " ") + self.assertTrue(ui_state.reply_id_unavailable) + + def test_refresh_message_highlight_marks_selected_message(self) -> None: + ui_state.current_window = 1 + ui_state.channel_list = ["Primary"] + ui_state.message_line_ranges = {"Primary": [(0, 2, 10), (2, 3, 20)]} + ui_state.selected_message = 2 + contact_ui.messages_pad = mock.Mock() + contact_ui.messages_win = mock.Mock() + contact_ui.messages_win.getmaxyx.return_value = (10, 40) + + contact_ui.refresh_message_highlight() + + contact_ui.messages_pad.chgat.assert_called_once_with(2, 1, 38, 20 | contact_ui.curses.A_REVERSE) + + def test_refresh_message_highlight_clears_when_messages_pane_is_not_active(self) -> None: + ui_state.current_window = 0 + ui_state.highlighted_message_range = (2, 3, 20) + contact_ui.messages_pad = mock.Mock() + contact_ui.messages_win = mock.Mock() + contact_ui.messages_win.getmaxyx.return_value = (10, 40) + + contact_ui.refresh_message_highlight() + + contact_ui.messages_pad.chgat.assert_called_once_with(2, 1, 38, 20) + self.assertEqual(ui_state.highlighted_message_range, ()) + + def test_handle_end_moves_messages_viewport_to_bottom(self) -> None: + ui_state.current_window = 1 + ui_state.start_index = [0, 0, 0] + contact_ui.messages_pad = mock.Mock() + contact_ui.messages_pad.getmaxyx.return_value = (100, 80) + contact_ui.messages_win = mock.Mock() + contact_ui.messages_win.getmaxyx.return_value = (12, 80) + contact_ui.packetlog_win = mock.Mock() + contact_ui.packetlog_win.getmaxyx.return_value = (1, 80) + + with mock.patch.object(contact_ui, "refresh_message_highlight"): + with mock.patch.object(contact_ui, "refresh_pad"): + with mock.patch.object(contact_ui, "draw_window_arrows"): + contact_ui.handle_end() + + self.assertEqual(ui_state.selected_message, 99) + self.assertGreater(ui_state.start_index[1], 0) + + def test_message_selection_can_reach_last_rendered_line(self) -> None: + ui_state.start_index = [0, 0, 0] + contact_ui.messages_pad = mock.Mock() + contact_ui.messages_pad.getmaxyx.return_value = (100, 80) + contact_ui.messages_win = mock.Mock() + contact_ui.messages_win.getmaxyx.return_value = (12, 80) + contact_ui.packetlog_win = mock.Mock() + contact_ui.packetlog_win.getmaxyx.return_value = (1, 80) + + contact_ui.set_message_selection(99) + + self.assertEqual(ui_state.selected_message, 99) + self.assertEqual(ui_state.start_index[1], 90) + + def test_switching_to_messages_redraws_at_bottom(self) -> None: + ui_state.current_window = 0 + ui_state.single_pane_mode = False + with mock.patch.object(contact_ui, "refresh_main_window"): + with mock.patch.object(contact_ui, "draw_window_arrows"): + with mock.patch.object(contact_ui, "draw_messages_window") as draw_messages_window: + with mock.patch.object(contact_ui, "refresh_message_highlight"): + contact_ui.handle_leftright(contact_ui.curses.KEY_RIGHT) + + draw_messages_window.assert_called_once_with(True) + + def test_move_message_selection_skips_all_lines_of_wrapped_message(self) -> None: + ui_state.channel_list = ["Primary"] + ui_state.message_line_ranges = {"Primary": [(0, 3, 10), (3, 4, 20), (4, 6, 30)]} + ui_state.selected_message = 1 + ui_state.start_index = [0, 0, 0] + contact_ui.messages_pad = mock.Mock() + contact_ui.messages_pad.getmaxyx.return_value = (6, 80) + contact_ui.messages_win = mock.Mock() + contact_ui.messages_win.getmaxyx.return_value = (12, 80) + contact_ui.packetlog_win = mock.Mock() + contact_ui.packetlog_win.getmaxyx.return_value = (1, 80) + + contact_ui.move_message_selection(1) + self.assertEqual(ui_state.selected_message, 3) + + contact_ui.move_message_selection(1) + self.assertEqual(ui_state.selected_message, 5) + + contact_ui.move_message_selection(-1) + self.assertEqual(ui_state.selected_message, 3) + def test_refresh_node_selection_reserves_scroll_arrow_column(self) -> None: ui_state.node_list = [101, 202] ui_state.selected_node = 1 diff --git a/tests/test_db_handler.py b/tests/test_db_handler.py index 6266bfc..0e378c6 100644 --- a/tests/test_db_handler.py +++ b/tests/test_db_handler.py @@ -45,6 +45,38 @@ class DbHandlerTests(unittest.TestCase): self.assertEqual(row, ("123", "hello", "Ack")) + def test_message_ids_are_migrated_and_reloaded_with_history(self) -> None: + db_handler.ensure_table_exists( + '"123_Primary_messages"', + "user_id TEXT, message_text TEXT, timestamp INTEGER, ack_type TEXT", + ) + with sqlite3.connect(config.db_file_path) as conn: + conn.execute( + 'INSERT INTO "123_Primary_messages" VALUES (?, ?, ?, ?)', + ("456", "original message", 1700000000, None), + ) + + db_handler.save_message_to_db("Primary", "456", "reply", packet_id=902, reply_id=901) + with sqlite3.connect(config.db_file_path) as conn: + columns = {row[1] for row in conn.execute('PRAGMA table_info("123_Primary_messages")')} + stored_ids = conn.execute( + 'SELECT packet_id, reply_id FROM "123_Primary_messages" WHERE message_text = ?', ("reply",) + ).fetchone() + conn.execute( + 'UPDATE "123_Primary_messages" SET packet_id = ? WHERE message_text = ?', (901, "original message") + ) + conn.commit() + + db_handler.load_messages_from_db() + + self.assertTrue({"packet_id", "reply_id"}.issubset(columns)) + self.assertEqual(stored_ids, (902, 901)) + self.assertEqual([packet_id for packet_id in ui_state.message_packet_ids["Primary"] if packet_id], [901, 902]) + self.assertEqual( + ui_state.all_messages["Primary"][-1][1], + f" reply", + ) + def test_update_node_info_in_db_fills_defaults_and_preserves_existing_values(self) -> None: db_handler.update_node_info_in_db(999, short_name="ABCD") diff --git a/tests/test_rx_handler.py b/tests/test_rx_handler.py index f1cc8c4..830d205 100644 --- a/tests/test_rx_handler.py +++ b/tests/test_rx_handler.py @@ -40,13 +40,50 @@ class RxHandlerTests(unittest.TestCase): with mock.patch.object(rx_handler, "get_name_from_database", return_value="SAT2"): rx_handler.on_receive(packet, interface=None) - self.assertEqual(request_ui_redraw.call_args_list, [mock.call(nodes=True), mock.call(messages=True, scroll_messages_to_bottom=True)]) + self.assertEqual( + request_ui_redraw.call_args_list, + [ + mock.call(nodes=True), + mock.call( + messages=True, + scroll_messages_to_bottom=True, + preserve_message_selection=False, + ), + ], + ) add_notification.assert_not_called() - save_message_to_db.assert_called_once_with("Primary", 222, "hello") + save_message_to_db.assert_called_once_with("Primary", 222, "hello", packet_id=None, reply_id=None) self.assertEqual(ui_state.all_messages["Primary"][-1][1], "hello") self.assertIn("SAT2:", ui_state.all_messages["Primary"][-1][0]) self.assertIn("[2]", ui_state.all_messages["Primary"][-1][0]) + def test_on_receive_preserves_message_selection_while_showing_new_message(self) -> None: + interface_state.myNodeNum = 111 + ui_state.current_window = 1 + ui_state.channel_list = ["Primary"] + ui_state.all_messages = {"Primary": []} + ui_state.selected_channel = 0 + packet = { + "from": 222, + "to": 999, + "channel": 0, + "hopStart": 1, + "hopLimit": 1, + "decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hello"}, + } + + with mock.patch.object(rx_handler, "refresh_node_list", return_value=False): + with mock.patch.object(rx_handler, "request_ui_redraw") as request_ui_redraw: + with mock.patch.object(rx_handler, "save_message_to_db"): + with mock.patch.object(rx_handler, "get_name_from_database", return_value="SAT2"): + rx_handler.on_receive(packet, interface=None) + + request_ui_redraw.assert_called_once_with( + messages=True, + scroll_messages_to_bottom=True, + preserve_message_selection=True, + ) + def test_on_receive_direct_message_adds_channel_and_notification(self) -> None: interface_state.myNodeNum = 111 ui_state.channel_list = ["Primary"] @@ -74,7 +111,33 @@ class RxHandlerTests(unittest.TestCase): request_ui_redraw.assert_called_once_with(channels=True) add_notification.assert_called_once_with(1) update_node_info_in_db.assert_called_once_with(222, chat_archived=False) - save_message_to_db.assert_called_once_with(222, 222, "dm") + save_message_to_db.assert_called_once_with(222, 222, "dm", packet_id=None, reply_id=None) + + def test_on_receive_displays_context_for_native_reply_id(self) -> None: + interface_state.myNodeNum = 111 + ui_state.channel_list = ["Primary"] + ui_state.all_messages = {"Primary": [("[06:00:00] >> SAT2: ", "hello world")]} + ui_state.message_packet_ids = {"Primary": [900]} + ui_state.selected_channel = 0 + packet = { + "id": 901, + "from": 222, + "to": 999, + "channel": 0, + "hopStart": 1, + "hopLimit": 1, + "decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hi", "replyId": 900}, + } + + with mock.patch.object(rx_handler, "refresh_node_list", return_value=False): + with mock.patch.object(rx_handler, "request_ui_redraw"): + with mock.patch.object(rx_handler, "save_message_to_db") as save_message_to_db: + with mock.patch.object(rx_handler, "get_name_from_database", return_value="NODE"): + rx_handler.on_receive(packet, interface=None) + + self.assertEqual(ui_state.all_messages["Primary"][-1][1], " hi") + self.assertEqual(ui_state.message_packet_ids["Primary"][-1], 901) + save_message_to_db.assert_called_once_with("Primary", 222, "hi", packet_id=901, reply_id=900) def test_on_receive_trims_packet_buffer_even_when_packet_is_undecoded(self) -> None: ui_state.packet_buffer = list(range(25)) diff --git a/tests/test_tx_handler.py b/tests/test_tx_handler.py index 72bb483..2e23c11 100644 --- a/tests/test_tx_handler.py +++ b/tests/test_tx_handler.py @@ -42,7 +42,7 @@ class TxHandlerTests(unittest.TestCase): onResponse=tx_handler.onAckNak, channelIndex=0, ) - save_message_to_db.assert_called_once_with("Primary", 111, "hello") + save_message_to_db.assert_called_once_with("Primary", 111, "hello", packet_id="req-1", reply_id=None) self.assertEqual(tx_handler.ack_naks["req-1"]["channel"], "Primary") self.assertEqual(tx_handler.ack_naks["req-1"]["messageIndex"], 1) self.assertEqual(tx_handler.ack_naks["req-1"]["timestamp"], 999) @@ -70,12 +70,27 @@ class TxHandlerTests(unittest.TestCase): ) self.assertEqual(tx_handler.ack_naks["req-2"]["channel"], 222) + def test_send_message_uses_native_reply_id_but_only_displays_reply_context_locally(self) -> None: + interface = mock.Mock() + interface.sendText.return_value = SimpleNamespace(id="req-3") + interface_state.interface = interface + interface_state.myNodeNum = 111 + ui_state.channel_list = ["Primary"] + ui_state.all_messages = {"Primary": []} + + with mock.patch.object(tx_handler, "save_message_to_db", return_value=123): + tx_handler.send_message("hello", channel=0, reply_id=77, reply_context=" ") + + self.assertEqual(interface.sendText.call_args.kwargs["text"], "hello") + self.assertEqual(interface.sendText.call_args.kwargs["replyId"], 77) + self.assertEqual(ui_state.all_messages["Primary"][-1][1], " hello") + def test_on_ack_nak_updates_message_for_explicit_ack(self) -> None: interface_state.myNodeNum = 111 ui_state.channel_list = ["Primary"] ui_state.selected_channel = 0 ui_state.all_messages = {"Primary": [("pending", "hello")]} - tx_handler.ack_naks["req"] = {"channel": "Primary", "messageIndex": 0, "timestamp": 55} + tx_handler.ack_naks["req"] = {"channel": "Primary", "messageIndex": 0, "timestamp": 55, "dbMessage": "hello"} packet = {"from": 222, "decoded": {"requestId": "req", "routing": {"errorReason": "NONE"}}} @@ -94,7 +109,7 @@ class TxHandlerTests(unittest.TestCase): ui_state.channel_list = ["Primary"] ui_state.selected_channel = 0 ui_state.all_messages = {"Primary": [("pending", "hello")]} - tx_handler.ack_naks["req"] = {"channel": "Primary", "messageIndex": 0, "timestamp": 55} + tx_handler.ack_naks["req"] = {"channel": "Primary", "messageIndex": 0, "timestamp": 55, "dbMessage": "hello"} packet = {"from": 111, "decoded": {"requestId": "req", "routing": {"errorReason": "NONE"}}}