diff --git a/modules/ui_presentation/include/ui_presentation/chat/chat_message_ref.h b/modules/ui_presentation/include/ui_presentation/chat/chat_message_ref.h index 682c3947..222ef79f 100644 --- a/modules/ui_presentation/include/ui_presentation/chat/chat_message_ref.h +++ b/modules/ui_presentation/include/ui_presentation/chat/chat_message_ref.h @@ -44,6 +44,7 @@ enum class MessageFailureKind : uint8_t { None, PeerKeyMissing, + ChannelKeyMissing, LocalIdentityMissing, RadioSendFailed, AckTimeout, diff --git a/modules/ui_presentation/include/ui_presentation/chat/chat_workspace_snapshot.h b/modules/ui_presentation/include/ui_presentation/chat/chat_workspace_snapshot.h index f38059c5..0d2637a5 100644 --- a/modules/ui_presentation/include/ui_presentation/chat/chat_workspace_snapshot.h +++ b/modules/ui_presentation/include/ui_presentation/chat/chat_workspace_snapshot.h @@ -19,6 +19,7 @@ struct ConversationRow ui::FixedText<64> subtitle; uint16_t unread_count = 0; + uint32_t last_timestamp = 0; bool selected = false; bool muted = false; @@ -128,6 +129,7 @@ inline void resetConversationRow(ConversationRow& row) row.title.clear(); row.subtitle.clear(); row.unread_count = 0; + row.last_timestamp = 0; row.selected = false; row.muted = false; row.kind = ConversationKind::None; diff --git a/modules/ui_presentation/include/ui_presentation/common/ui_action_result.h b/modules/ui_presentation/include/ui_presentation/common/ui_action_result.h index 3a70817d..f7e65729 100644 --- a/modules/ui_presentation/include/ui_presentation/common/ui_action_result.h +++ b/modules/ui_presentation/include/ui_presentation/common/ui_action_result.h @@ -14,6 +14,13 @@ enum class UiActionFailure : uint8_t Unsupported, Busy, StorageError, + PeerKeyMissing, + ChannelKeyMissing, + TxDisabled, + RadioOffline, + DutyCycleLimited, + LocalIdentityMissing, + RadioTxFailed, }; struct UiActionResult diff --git a/modules/ui_presentation/tests/test_chat_workspace_model.cpp b/modules/ui_presentation/tests/test_chat_workspace_model.cpp index 8469b565..ed184d78 100644 --- a/modules/ui_presentation/tests/test_chat_workspace_model.cpp +++ b/modules/ui_presentation/tests/test_chat_workspace_model.cpp @@ -35,6 +35,7 @@ int main() ui::chat::ChatWorkspaceSnapshot reset_probe; reset_probe.header.valid = true; reset_probe.conversation_count = 1; + reset_probe.conversations[0].last_timestamp = 1234; reset_probe.conversations[0].last_delivery = ui::chat::MessageDeliveryState::Delivered; reset_probe.message_count = 1; @@ -48,6 +49,7 @@ int main() ui::chat::resetChatWorkspaceSnapshot(reset_probe); assert(!reset_probe.header.valid); assert(reset_probe.conversation_count == 0); + assert(reset_probe.conversations[0].last_timestamp == 0); assert(reset_probe.conversations[0].last_delivery == ui::chat::MessageDeliveryState::Unknown); assert(reset_probe.message_count == 0); diff --git a/modules/ui_shared/src/ui/components/two_pane_nav.cpp b/modules/ui_shared/src/ui/components/two_pane_nav.cpp index c824e3ad..555163a1 100644 --- a/modules/ui_shared/src/ui/components/two_pane_nav.cpp +++ b/modules/ui_shared/src/ui/components/two_pane_nav.cpp @@ -775,21 +775,7 @@ void on_ui_refreshed(Binding* binding) { if (!binding_alive(binding) || !binding->group) return; lv_group_set_editing(binding->group, false); - prepare_touch_routing(binding); - - if (binding->column == FocusColumn::List) - { - rebind_by_column(binding); - return; - } - - lv_obj_t* focused = lv_group_get_focused(binding->group); - if (is_visible(focused)) - { - return; - } - - bind_filter_column(binding); + rebind_by_column(binding); } void focus_filter(Binding* binding) diff --git a/modules/ui_shared/src/ui/presentation_sources/chat_presentation_source.cpp b/modules/ui_shared/src/ui/presentation_sources/chat_presentation_source.cpp index df531808..6e7e5ee6 100644 --- a/modules/ui_shared/src/ui/presentation_sources/chat_presentation_source.cpp +++ b/modules/ui_shared/src/ui/presentation_sources/chat_presentation_source.cpp @@ -26,7 +26,7 @@ void copyNodeLabel(ui::FixedText<32>& out, ::chat::NodeId node_id) { if (node_id == 0) { - ui::copyText(out, "Me"); + ui::copyText(out, "Unknown"); return; } @@ -82,6 +82,8 @@ ui::chat::MessageFailureKind mapDeliveryFailure( return ui::chat::MessageFailureKind::None; case ::chat::delivery::DeliveryFailureKind::PeerKeyMissing: return ui::chat::MessageFailureKind::PeerKeyMissing; + case ::chat::delivery::DeliveryFailureKind::ChannelKeyMissing: + return ui::chat::MessageFailureKind::ChannelKeyMissing; case ::chat::delivery::DeliveryFailureKind::LocalIdentityMissing: return ui::chat::MessageFailureKind::LocalIdentityMissing; case ::chat::delivery::DeliveryFailureKind::RadioSendFailed: @@ -141,6 +143,7 @@ bool ChatPresentationSource::buildChatWorkspaceSnapshot( row.kind = row.id.kind; row.protocol = row.id.protocol; row.unread_count = meta.unread < 0 ? 0U : static_cast(meta.unread); + row.last_timestamp = meta.last_timestamp; row.selected = row.id == request.selected; copyString(row.title, meta.name); copyString(row.subtitle, meta.preview); diff --git a/modules/ui_shared/src/ui/presentation_sources/legacy_chat_action_sink.cpp b/modules/ui_shared/src/ui/presentation_sources/legacy_chat_action_sink.cpp index 9ef91d00..b12b40a1 100644 --- a/modules/ui_shared/src/ui/presentation_sources/legacy_chat_action_sink.cpp +++ b/modules/ui_shared/src/ui/presentation_sources/legacy_chat_action_sink.cpp @@ -6,6 +6,45 @@ namespace ui::presentation_sources { +namespace +{ + +ui::UiActionFailure mapMeshFailure(::chat::MeshOperationFailure failure) +{ + switch (failure) + { + case ::chat::MeshOperationFailure::InvalidInput: + return ui::UiActionFailure::InvalidInput; + case ::chat::MeshOperationFailure::Unsupported: + return ui::UiActionFailure::Unsupported; + case ::chat::MeshOperationFailure::NotReady: + return ui::UiActionFailure::NotReady; + case ::chat::MeshOperationFailure::TxDisabled: + return ui::UiActionFailure::TxDisabled; + case ::chat::MeshOperationFailure::RadioOffline: + return ui::UiActionFailure::RadioOffline; + case ::chat::MeshOperationFailure::DutyCycleLimited: + return ui::UiActionFailure::DutyCycleLimited; + case ::chat::MeshOperationFailure::LocalIdentityMissing: + return ui::UiActionFailure::LocalIdentityMissing; + case ::chat::MeshOperationFailure::PeerKeyMissing: + return ui::UiActionFailure::PeerKeyMissing; + case ::chat::MeshOperationFailure::ChannelKeyMissing: + return ui::UiActionFailure::ChannelKeyMissing; + case ::chat::MeshOperationFailure::Busy: + return ui::UiActionFailure::Busy; + case ::chat::MeshOperationFailure::RadioTxFailed: + return ui::UiActionFailure::RadioTxFailed; + case ::chat::MeshOperationFailure::EncodeFailed: + case ::chat::MeshOperationFailure::CryptoFailed: + case ::chat::MeshOperationFailure::Unknown: + case ::chat::MeshOperationFailure::None: + default: + return ui::UiActionFailure::Rejected; + } +} + +} // namespace LegacyChatActionSink::LegacyChatActionSink(::chat::ChatService& chat_service) : chat_service_(chat_service) @@ -41,14 +80,14 @@ ui::UiActionResult LegacyChatActionSink::sendMessage( } const std::string text(message.text, message.text_len); - const ::chat::MessageId msg_id = - chat_service_.sendText(core_id.channel, text, core_id.peer); - if (msg_id == 0) + const ::chat::MeshSendResult result = + chat_service_.sendTextDetailed(core_id.channel, text, core_id.peer); + if (!result.ok || result.msg_id == 0) { - return ui::UiActionResult::fail(ui::UiActionFailure::Rejected); + return ui::UiActionResult::fail(mapMeshFailure(result.failure)); } - const ::chat::ChatMessage* sent = chat_service_.getMessage(msg_id); + const ::chat::ChatMessage* sent = chat_service_.getMessage(result.msg_id); if (sent != nullptr && sent->status == ::chat::MessageStatus::Failed) { return ui::UiActionResult::fail(ui::UiActionFailure::Rejected); diff --git a/modules/ui_shared/src/ui/presentation_sources/team_chat_presentation_source.cpp b/modules/ui_shared/src/ui/presentation_sources/team_chat_presentation_source.cpp index 8bba2d27..90f492cf 100644 --- a/modules/ui_shared/src/ui/presentation_sources/team_chat_presentation_source.cpp +++ b/modules/ui_shared/src/ui/presentation_sources/team_chat_presentation_source.cpp @@ -214,6 +214,7 @@ bool TeamChatPresentationSource::buildChatWorkspaceSnapshot( (void)projector.project(entries.back(), display); ui::copyText(conversation.subtitle, display.summary.c_str()); + conversation.last_timestamp = entries.back().ts; conversation.last_delivery = entries.back().incoming ? ui::chat::MessageDeliveryState::Received : ui::chat::MessageDeliveryState::Sent; diff --git a/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp b/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp index f11ba2df..8f66a348 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp @@ -577,7 +577,7 @@ void ChatConversationScreen::createMessageItem(const ::ui::chat::MessageRow& row } else if (!row.sender_label.empty()) { - sender = row.sender_label.c_str(); + sender = inferred_sender.empty() ? row.sender_label.c_str() : inferred_sender; } else if (row.sender_node_id == 0) { diff --git a/modules/ui_shared/src/ui/screens/chat/chat_message_list_layout.cpp b/modules/ui_shared/src/ui/screens/chat/chat_message_list_layout.cpp index f9a36553..3f0b0705 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_message_list_layout.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_message_list_layout.cpp @@ -38,10 +38,13 @@ struct Metrics int filter_button_height = 28; int list_item_height = 28; int name_x = 10; - int preview_x = 120; - int preview_width = 130; + int name_width = 96; + int preview_x = 112; + int preview_width = 112; int unread_x = 42; + int unread_width = 28; int time_x = 10; + int time_width = 38; }; Metrics current_metrics() @@ -54,10 +57,13 @@ Metrics current_metrics() if (profile.large_touch_hitbox) { metrics.name_x = 16; + metrics.name_width = 146; metrics.preview_x = 170; - metrics.preview_width = 280; + metrics.preview_width = 236; metrics.unread_x = 72; + metrics.unread_width = 44; metrics.time_x = 16; + metrics.time_width = 52; } else { @@ -135,10 +141,19 @@ void format_time_hhmm(char out[16], uint32_t ts) std::string truncate_preview(const std::string& text) { - static constexpr size_t kMaxPreviewBytes = 18; - if (text.size() <= kMaxPreviewBytes) + std::string one_line = text; + for (char& ch : one_line) { - return text; + if (ch == '\r' || ch == '\n' || ch == '\t') + { + ch = ' '; + } + } + + const size_t kMaxPreviewBytes = ::ui::page_profile::current().large_touch_hitbox ? 42U : 24U; + if (one_line.size() <= kMaxPreviewBytes) + { + return one_line; } auto utf8_char_bytes = [](unsigned char lead) -> size_t @@ -163,9 +178,9 @@ std::string truncate_preview(const std::string& text) }; size_t safe_len = 0; - while (safe_len < text.size()) + while (safe_len < one_line.size()) { - const size_t next = utf8_char_bytes(static_cast(text[safe_len])); + const size_t next = utf8_char_bytes(static_cast(one_line[safe_len])); if (safe_len + next > kMaxPreviewBytes) { break; @@ -177,7 +192,7 @@ std::string truncate_preview(const std::string& text) safe_len = kMaxPreviewBytes; } - std::string out = text.substr(0, safe_len); + std::string out = one_line.substr(0, safe_len); out.append("..."); return out; } @@ -201,12 +216,20 @@ void replace_all(std::string& text, const char* from, const char* to) std::string compact_list_name(const std::string& name) { - if (!::ui::components::info_card::use_tdeck_layout()) + std::string compact = name; + for (char& ch : compact) { - return name; + if (ch == '\r' || ch == '\n' || ch == '\t') + { + ch = ' '; + } + } + + if (!::ui::components::info_card::use_tdeck_layout()) + { + return compact; } - std::string compact = name; replace_all(compact, ::ui::i18n::tr("Broadcast"), ::ui::i18n::tr("Bcast")); replace_all(compact, "Primary", "Pri"); replace_all(compact, "Secondary", "Sec"); @@ -233,6 +256,17 @@ void style_filter_label(lv_obj_t* label) lv_label_set_long_mode(label, LV_LABEL_LONG_CLIP); } +void apply_single_line(lv_obj_t* label) +{ + if (!label) + { + return; + } + + lv_label_set_long_mode(label, LV_LABEL_LONG_DOT); + ::ui::components::info_card::apply_single_line_label(label); +} + lv_obj_t* create_root(lv_obj_t* parent) { return ::ui::components::two_pane_layout::create_root(parent); @@ -342,20 +376,28 @@ MessageItemWidgets create_message_item(lv_obj_t* parent) w.name_label = lv_label_create(w.btn); lv_obj_add_flag(w.name_label, LV_OBJ_FLAG_EVENT_BUBBLE); lv_obj_align(w.name_label, LV_ALIGN_LEFT_MID, metrics.name_x, 0); + lv_obj_set_width(w.name_label, metrics.name_width); + apply_single_line(w.name_label); w.preview_label = lv_label_create(w.btn); lv_obj_add_flag(w.preview_label, LV_OBJ_FLAG_EVENT_BUBBLE); lv_obj_align(w.preview_label, LV_ALIGN_LEFT_MID, metrics.preview_x, 0); - lv_label_set_long_mode(w.preview_label, LV_LABEL_LONG_DOT); lv_obj_set_width(w.preview_label, metrics.preview_width); + apply_single_line(w.preview_label); w.time_label = lv_label_create(w.btn); lv_obj_add_flag(w.time_label, LV_OBJ_FLAG_EVENT_BUBBLE); lv_obj_align(w.time_label, LV_ALIGN_RIGHT_MID, -metrics.time_x, 0); + lv_obj_set_width(w.time_label, metrics.time_width); + lv_obj_set_style_text_align(w.time_label, LV_TEXT_ALIGN_RIGHT, 0); + apply_single_line(w.time_label); w.unread_label = lv_label_create(w.btn); lv_obj_add_flag(w.unread_label, LV_OBJ_FLAG_EVENT_BUBBLE); lv_obj_align(w.unread_label, LV_ALIGN_RIGHT_MID, -metrics.unread_x, 0); + lv_obj_set_width(w.unread_label, metrics.unread_width); + lv_obj_set_style_text_align(w.unread_label, LV_TEXT_ALIGN_RIGHT, 0); + apply_single_line(w.unread_label); } return w; @@ -373,10 +415,22 @@ void populate_message_item(const MessageItemWidgets& widgets, { ::ui::components::info_card::apply_single_line_label(widgets.name_label); } + else + { + apply_single_line(widgets.name_label); + } const std::string preview = truncate_preview(conv.preview); lv_label_set_text(widgets.preview_label, preview.c_str()); ::ui::fonts::apply_chat_content_font(widgets.preview_label, preview.c_str()); + if (use_info_card) + { + ::ui::components::info_card::apply_single_line_label(widgets.preview_label); + } + else + { + apply_single_line(widgets.preview_label); + } char time_buf[16]; format_time_hhmm(time_buf, conv.last_timestamp); @@ -386,6 +440,10 @@ void populate_message_item(const MessageItemWidgets& widgets, { ::ui::components::info_card::apply_single_line_label(widgets.time_label); } + else + { + apply_single_line(widgets.time_label); + } if (conv.unread > 0) { @@ -403,6 +461,10 @@ void populate_message_item(const MessageItemWidgets& widgets, { ::ui::components::info_card::apply_single_line_label(widgets.unread_label); } + else + { + apply_single_line(widgets.unread_label); + } } lv_obj_t* create_placeholder(lv_obj_t* parent) diff --git a/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp b/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp index e1c0ab6b..3bf83807 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp @@ -112,7 +112,7 @@ chat::ConversationMeta legacyConversationMetaFromRow( meta.name = row.title.c_str(); meta.preview = row.subtitle.c_str(); meta.unread = static_cast(row.unread_count); - meta.last_timestamp = 0; + meta.last_timestamp = row.last_timestamp; return meta; } @@ -154,7 +154,7 @@ bool legacyTeamConversationMetaFromSnapshot( out.name = row.title.c_str(); out.preview = row.subtitle.c_str(); out.unread = static_cast(row.unread_count); - out.last_timestamp = 0; + out.last_timestamp = row.last_timestamp; return true; } @@ -752,7 +752,39 @@ void UiController::handleSendMessage(const std::string& text) if (!result.ok) { const char* message = "Send failed"; - if (result.failure == ::ui::UiActionFailure::Unsupported) + if (result.failure == ::ui::UiActionFailure::ChannelKeyMissing) + { + message = "Channel key missing"; + } + else if (result.failure == ::ui::UiActionFailure::PeerKeyMissing) + { + message = "Peer key missing"; + } + else if (result.failure == ::ui::UiActionFailure::TxDisabled) + { + message = "TX disabled"; + } + else if (result.failure == ::ui::UiActionFailure::RadioOffline) + { + message = "Radio offline"; + } + else if (result.failure == ::ui::UiActionFailure::DutyCycleLimited) + { + message = "TX rate limited"; + } + else if (result.failure == ::ui::UiActionFailure::RadioTxFailed) + { + message = "Radio TX failed"; + } + else if (result.failure == ::ui::UiActionFailure::LocalIdentityMissing) + { + message = "Identity missing"; + } + else if (result.failure == ::ui::UiActionFailure::Busy) + { + message = "Radio busy"; + } + else if (result.failure == ::ui::UiActionFailure::Unsupported) { message = "Conversation unsupported"; } diff --git a/modules/ui_shared/src/ui/screens/chat_watch/chat_conversation_components_watch.cpp b/modules/ui_shared/src/ui/screens/chat_watch/chat_conversation_components_watch.cpp index 61f02436..c7f7d28c 100644 --- a/modules/ui_shared/src/ui/screens/chat_watch/chat_conversation_components_watch.cpp +++ b/modules/ui_shared/src/ui/screens/chat_watch/chat_conversation_components_watch.cpp @@ -6,12 +6,14 @@ #include "ui/ui_theme.h" #include "ui_presentation/chat/chat_workspace_snapshot.h" +#include #include namespace { constexpr lv_coord_t kActionBarHeight = 36; constexpr lv_coord_t kActionButtonHeight = 26; +constexpr size_t kMaxPrefixedSenderLen = 20; ::ui::chat::MessageDeliveryState delivery_from_message_status( chat::MessageStatus status) @@ -64,6 +66,63 @@ std::string format_team_rich_payload_text( } return out; } + +bool sender_token_is_valid(const std::string& sender) +{ + if (sender.empty() || sender.size() > kMaxPrefixedSenderLen) + { + return false; + } + for (char c : sender) + { + const unsigned char uc = static_cast(c); + if (!(std::isalnum(uc) || c == '_' || c == '-' || c == '.')) + { + return false; + } + } + return true; +} + +bool split_prefixed_sender_text(const std::string& text, + std::string* out_sender, + std::string* out_body) +{ + if (!out_sender || !out_body || text.empty()) + { + return false; + } + + const size_t sep = text.find(':'); + if (sep == std::string::npos || sep == 0 || sep > kMaxPrefixedSenderLen) + { + return false; + } + + std::string sender = text.substr(0, sep); + while (!sender.empty() && sender.back() == ' ') + { + sender.pop_back(); + } + if (!sender_token_is_valid(sender)) + { + return false; + } + + size_t body_start = sep + 1; + while (body_start < text.size() && text[body_start] == ' ') + { + ++body_start; + } + if (body_start >= text.size()) + { + return false; + } + + *out_sender = sender; + *out_body = text.substr(body_start); + return true; +} } // namespace namespace chat::ui @@ -182,13 +241,27 @@ void ChatConversationScreen::createMessageItem(const ::ui::chat::MessageRow& row LV_FLEX_ALIGN_START); std::string display_text; - const std::string body_text = + std::string body_text = row.has_team_rich_payload ? format_team_rich_payload_text(row.team_rich_payload) : std::string(row.text.c_str()); - if (!row.outgoing && !row.sender_label.empty()) + std::string sender_label = row.sender_label.c_str(); + if (!row.outgoing && + conv_.protocol == chat::MeshProtocol::MeshCore && + conv_.peer == 0 && + row.sender_node_id == 0) { - display_text = row.sender_label.c_str(); + std::string parsed_sender; + std::string parsed_body; + if (split_prefixed_sender_text(body_text, &parsed_sender, &parsed_body)) + { + sender_label = parsed_sender; + body_text = parsed_body; + } + } + if (!row.outgoing && !sender_label.empty()) + { + display_text = sender_label; display_text += ": "; display_text += body_text; } diff --git a/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp b/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp index 2de6a137..6af27221 100644 --- a/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp @@ -1952,6 +1952,44 @@ static void on_node_info_back_clicked(lv_event_t* /*e*/) close_node_info_screen(); } +static const char* discovery_failure_message(chat::MeshOperationFailure failure, + const char* fallback) +{ + switch (failure) + { + case chat::MeshOperationFailure::NotReady: + return "Mesh not ready"; + case chat::MeshOperationFailure::TxDisabled: + return "TX disabled"; + case chat::MeshOperationFailure::RadioOffline: + return "Radio offline"; + case chat::MeshOperationFailure::DutyCycleLimited: + return "TX rate limited"; + case chat::MeshOperationFailure::LocalIdentityMissing: + return "Identity missing"; + case chat::MeshOperationFailure::Busy: + return "Radio busy"; + case chat::MeshOperationFailure::RadioTxFailed: + return "Radio TX failed"; + case chat::MeshOperationFailure::EncodeFailed: + return "Packet build failed"; + case chat::MeshOperationFailure::CryptoFailed: + return "Signature failed"; + case chat::MeshOperationFailure::Unsupported: + return "Unsupported"; + case chat::MeshOperationFailure::InvalidInput: + return "Invalid action"; + case chat::MeshOperationFailure::PeerKeyMissing: + return "Peer key missing"; + case chat::MeshOperationFailure::ChannelKeyMissing: + return "Channel key missing"; + case chat::MeshOperationFailure::None: + case chat::MeshOperationFailure::Unknown: + break; + } + return fallback; +} + static void execute_discovery_command(uint8_t command_index) { DiscoveryActionSpec spec{}; @@ -1990,10 +2028,14 @@ static void execute_discovery_command(uint8_t command_index) lv_timer_del(g_contacts_state.discover_scan_timer); g_contacts_state.discover_scan_timer = nullptr; } - const bool ok = g_contacts_state.chat_service->triggerDiscoveryAction(chat::MeshDiscoveryAction::ScanLocal); - if (!ok) + const chat::MeshActionResult result = + g_contacts_state.chat_service->triggerDiscoveryActionDetailed( + chat::MeshDiscoveryAction::ScanLocal); + if (!result.ok) { - ::ui::SystemNotification::show("Scan failed", 2000); + ::ui::SystemNotification::show( + discovery_failure_message(result.failure, "Scan failed"), + 2000); return; } ::ui::SystemNotification::show("Scanning 5s...", 1800); @@ -2006,8 +2048,9 @@ static void execute_discovery_command(uint8_t command_index) (spec.command == DiscoveryActionCommand::SendIdLocal) ? chat::MeshDiscoveryAction::SendIdLocal : chat::MeshDiscoveryAction::SendIdBroadcast; - const bool ok = g_contacts_state.chat_service->triggerDiscoveryAction(action); - if (ok) + const chat::MeshActionResult result = + g_contacts_state.chat_service->triggerDiscoveryActionDetailed(action); + if (result.ok) { ::ui::SystemNotification::show( (spec.command == DiscoveryActionCommand::SendIdLocal) ? "ID local sent" : "ID bcast sent", @@ -2016,7 +2059,9 @@ static void execute_discovery_command(uint8_t command_index) else { ::ui::SystemNotification::show( - (spec.command == DiscoveryActionCommand::SendIdLocal) ? "ID local fail" : "ID bcast fail", + discovery_failure_message( + result.failure, + (spec.command == DiscoveryActionCommand::SendIdLocal) ? "ID local fail" : "ID bcast fail"), 2000); } } @@ -2547,22 +2592,10 @@ void refresh_ui() if (g_contacts_state.current_mode == ContactsMode::Contacts) { status_text = format_time_status(node.last_seen); - const char* proto = node_protocol_short_label(node.protocol); - if (proto[0] != '\0') - { - status_text += " "; - status_text += proto; - } } else if (g_contacts_state.current_mode == ContactsMode::Nearby) { status_text = format_time_status(node.last_seen); - const char* proto = node_protocol_short_label(node.protocol); - if (proto[0] != '\0') - { - status_text += " "; - status_text += proto; - } } else if (g_contacts_state.current_mode == ContactsMode::Ignored) { @@ -2573,12 +2606,6 @@ void refresh_ui() status_text += " / "; status_text += seen; } - const char* proto = node_protocol_short_label(node.protocol); - if (proto[0] != '\0') - { - status_text += " "; - status_text += proto; - } } else if (g_contacts_state.current_mode == ContactsMode::Team) { diff --git a/modules/ui_shared/src/ui/screens/contacts/contacts_page_layout.cpp b/modules/ui_shared/src/ui/screens/contacts/contacts_page_layout.cpp index 8849fd15..b2ad9852 100644 --- a/modules/ui_shared/src/ui/screens/contacts/contacts_page_layout.cpp +++ b/modules/ui_shared/src/ui/screens/contacts/contacts_page_layout.cpp @@ -28,6 +28,35 @@ namespace layout static constexpr int kButtonSpacing = 3; static constexpr int kPanelGap = 3; // Gap between filter and list columns +const char* node_protocol_short_label(chat::contacts::NodeProtocolType protocol) +{ + switch (protocol) + { + case chat::contacts::NodeProtocolType::LXMF: + return "LX"; + case chat::contacts::NodeProtocolType::RNode: + return "RN"; + case chat::contacts::NodeProtocolType::MeshCore: + return "MC"; + case chat::contacts::NodeProtocolType::Meshtastic: + return "MT"; + default: + return ""; + } +} + +bool should_prefix_node_protocol(ContactsMode mode, + chat::contacts::NodeProtocolType protocol) +{ + if (protocol == chat::contacts::NodeProtocolType::Unknown) + { + return false; + } + return mode == ContactsMode::Contacts || + mode == ContactsMode::Nearby || + mode == ContactsMode::Ignored; +} + lv_obj_t* create_root(lv_obj_t* parent) { const auto& profile = ::ui::page_profile::current(); @@ -198,7 +227,7 @@ void ensure_list_subcontainers() lv_obj_t* create_list_item(lv_obj_t* parent, const chat::contacts::NodeInfo& node, - ContactsMode /*mode*/, + ContactsMode mode, const char* status_text) { const auto& profile = ::ui::page_profile::current(); @@ -229,6 +258,14 @@ lv_obj_t* create_list_item(lv_obj_t* parent, { display_name = node.short_name; } + if (should_prefix_node_protocol(mode, node.protocol)) + { + const char* proto = node_protocol_short_label(node.protocol); + if (proto[0] != '\0') + { + display_name = "[" + std::string(proto) + "] " + display_name; + } + } if (::ui::components::info_card::use_tdeck_layout()) { diff --git a/modules/ui_shared/tests/test_chat_presentation_source.cpp b/modules/ui_shared/tests/test_chat_presentation_source.cpp index 0ab95333..98b7ef3b 100644 --- a/modules/ui_shared/tests/test_chat_presentation_source.cpp +++ b/modules/ui_shared/tests/test_chat_presentation_source.cpp @@ -6,8 +6,10 @@ #include "chat/usecase/chat_service.h" #include "ui/presentation_sources/chat_presentation_source.h" #include "ui/presentation_sources/legacy_chat_action_sink.h" +#include "sys/clock.h" #include +#include #include #include #include @@ -34,6 +36,20 @@ class FakeMeshAdapter final : public ::chat::IMeshAdapter return send_ok; } + ::chat::MeshSendResult sendTextDetailed(::chat::ChannelId channel, + const std::string& text, + ::chat::MessageId forced_msg_id = 0, + ::chat::NodeId peer = 0) override + { + ::chat::MessageId msg_id = forced_msg_id; + const bool ok = sendText(channel, text, &msg_id, peer); + if (ok) + { + return ::chat::MeshSendResult::success(msg_id); + } + return ::chat::MeshSendResult::fail(send_failure, fail_returns_msg_id ? msg_id : 0); + } + bool pollIncomingText(::chat::MeshIncomingText* out) override { if (!out || incoming.empty()) @@ -62,6 +78,8 @@ class FakeMeshAdapter final : public ::chat::IMeshAdapter int send_count = 0; bool send_ok = true; + bool fail_returns_msg_id = true; + ::chat::MeshOperationFailure send_failure = ::chat::MeshOperationFailure::Unknown; ::chat::MessageId next_id = 100; ::chat::ChannelId last_channel = ::chat::ChannelId::PRIMARY; std::string last_text; @@ -97,6 +115,15 @@ ui::chat::ConversationId broadcastConversation() return id; } +ui::chat::ConversationId meshCoreBroadcastConversation() +{ + ui::chat::ConversationId id; + id.kind = ui::chat::ConversationKind::Channel; + id.protocol = ui::chat::ChatProtocolKind::MeshCore; + id.primary = static_cast(::chat::ChannelId::PRIMARY); + return id; +} + ui::chat::ConversationId systemConversation() { ui::chat::ConversationId id; @@ -110,6 +137,11 @@ ui::chat::ConversationId systemConversation() int main() { + sys::set_epoch_seconds_provider([]() -> uint32_t + { return 1700000000U; }); + sys::set_millis_provider([]() -> uint32_t + { return 5000U; }); + ::chat::ChatModel model; FakeMeshAdapter mesh; ::chat::RamStore store; @@ -146,10 +178,14 @@ int main() assert(snapshot.conversation_count == 1); assert(snapshot.conversations[0].id == ada); assert(snapshot.conversations[0].selected); + assert(snapshot.conversations[0].last_timestamp != 0); assert(std::strcmp(snapshot.conversations[0].title.c_str(), "04D2") == 0); assert(snapshot.message_count == 1); assert(snapshot.messages[0].conversation == ada); assert(snapshot.messages[0].outgoing); + assert(std::strcmp(snapshot.messages[0].time_label.c_str(), "") != 0); + assert(std::strtoul(snapshot.messages[0].time_label.c_str(), nullptr, 10) == + snapshot.conversations[0].last_timestamp); assert(snapshot.messages[0].delivery == ui::chat::MessageDeliveryState::Delivered); assert(snapshot.messages[0].failure == ui::chat::MessageFailureKind::None); @@ -158,10 +194,23 @@ int main() assert(snapshot.composer_enabled); mesh.send_ok = false; + mesh.send_failure = ::chat::MeshOperationFailure::PeerKeyMissing; const ui::chat::SendMessageView failed_send{ada, "fail", 4}; const auto rejected_send = sink.sendMessage(failed_send); assert(!rejected_send.ok); - assert(rejected_send.failure == ui::UiActionFailure::Rejected); + assert(rejected_send.failure == ui::UiActionFailure::PeerKeyMissing); + + mesh.fail_returns_msg_id = false; + mesh.send_failure = ::chat::MeshOperationFailure::ChannelKeyMissing; + const auto channel_key_send = sink.sendMessage(failed_send); + assert(!channel_key_send.ok); + assert(channel_key_send.failure == ui::UiActionFailure::ChannelKeyMissing); + + mesh.send_failure = ::chat::MeshOperationFailure::RadioOffline; + const auto radio_offline_send = sink.sendMessage(failed_send); + assert(!radio_offline_send.ok); + assert(radio_offline_send.failure == ui::UiActionFailure::RadioOffline); + mesh.fail_returns_msg_id = true; assert(delivery_read_model.upsert(::chat::delivery::toFailedDeliveryRecord( ::chat::delivery::ChatDeliveryRef{0, 101, 0}, ::chat::delivery::SendFailureKind::PeerKeyMissing))); @@ -171,6 +220,14 @@ int main() assert(snapshot.messages[1].failure == ui::chat::MessageFailureKind::PeerKeyMissing); + assert(delivery_read_model.upsert(::chat::delivery::toFailedDeliveryRecord( + ::chat::delivery::ChatDeliveryRef{0, 101, 0}, + ::chat::delivery::SendFailureKind::ChannelKeyMissing))); + assert(source.buildChatWorkspaceSnapshot(request, snapshot)); + assert(snapshot.message_count == 2); + assert(snapshot.messages[1].failure == + ui::chat::MessageFailureKind::ChannelKeyMissing); + assert(sink.markRead(ada).ok); ::chat::MeshIncomingText incoming{}; @@ -192,6 +249,26 @@ int main() assert(snapshot.messages[0].sender_node_id == 0x648144D4); assert(std::strcmp(snapshot.messages[0].sender_label.c_str(), "44D4") == 0); + service.setActiveProtocol(::chat::MeshProtocol::MeshCore); + ::chat::MeshIncomingText unknown_meshcore_incoming{}; + unknown_meshcore_incoming.channel = ::chat::ChannelId::PRIMARY; + unknown_meshcore_incoming.from = 0; + unknown_meshcore_incoming.to = 0xFFFFFFFFUL; + unknown_meshcore_incoming.msg_id = 901; + unknown_meshcore_incoming.text = "mc sender unknown"; + mesh.incoming.push_back(unknown_meshcore_incoming); + service.processIncoming(); + + const ui::chat::ConversationId meshcore_broadcast = meshCoreBroadcastConversation(); + request.selected = meshcore_broadcast; + assert(source.buildChatWorkspaceSnapshot(request, snapshot)); + assert(snapshot.header.valid); + assert(snapshot.message_count == 1); + assert(snapshot.messages[0].conversation == meshcore_broadcast); + assert(!snapshot.messages[0].outgoing); + assert(snapshot.messages[0].sender_node_id == 0); + assert(std::strcmp(snapshot.messages[0].sender_label.c_str(), "Unknown") == 0); + const ui::chat::ConversationId team = teamConversation(); assert(!sink.selectConversation(team).ok); assert(!sink.markRead(team).ok); @@ -216,5 +293,7 @@ int main() assert(!system_send.ok); assert(system_send.failure == ui::UiActionFailure::Unsupported); + sys::set_epoch_seconds_provider(nullptr); + sys::set_millis_provider(nullptr); return 0; } diff --git a/modules/ui_shared/tests/test_team_chat_presentation_source.cpp b/modules/ui_shared/tests/test_team_chat_presentation_source.cpp index 79f59401..59baafb4 100644 --- a/modules/ui_shared/tests/test_team_chat_presentation_source.cpp +++ b/modules/ui_shared/tests/test_team_chat_presentation_source.cpp @@ -106,6 +106,7 @@ int main() ui::chat::ChatProtocolKind::TrailMate); assert(std::strcmp(overview.conversations[0].title.c_str(), "Alpha") == 0); assert(overview.conversations[0].unread_count == 2); + assert(overview.conversations[0].last_timestamp == 102); assert(overview.message_count == 0); const ui::chat::ConversationId team_id = overview.conversations[0].id;