diff --git a/docs/specification/CHAT_PRESENTATION_IDENTITY_SPEC.md b/docs/specification/CHAT_PRESENTATION_IDENTITY_SPEC.md index 13530126..3ce0c200 100644 --- a/docs/specification/CHAT_PRESENTATION_IDENTITY_SPEC.md +++ b/docs/specification/CHAT_PRESENTATION_IDENTITY_SPEC.md @@ -45,6 +45,10 @@ and UI presentation identity types. failure does not roll back the UI selection because selection is presentation state, while the sink is a synchronization hook for app-side side effects such as read cursors and mark-read behavior. +- A selected conversation whose protocol differs from the active runtime + protocol is still a valid readable conversation. It is not a valid send + target. Renderers may show it and allow message inspection, but compose, + reply, retry, and direct send actions must be disabled or rejected. - `ChatWorkspaceModel` must not own or expose `ChatService`, `ContactService`, `IMeshAdapter`, stores, protocol adapters, or `chat::ConversationId`. - `ChatWorkspaceSnapshot` must not expose `ChatService`, `ContactService`, @@ -133,6 +137,11 @@ ChatWorkspaceModel::markRead(...) token back to `core_chat` identity, `MeshSession`, or legacy send behavior belongs to the Source/Sink adapter layer, not to `ui_presentation`. +The Source/Sink adapter must preserve the protocol field during that mapping. +Dropping the protocol and sending only by channel/peer is invalid because it +turns a readable cross-protocol conversation into a send target for the active +protocol. + ## Source/Sink Adapter Contract Chat presentation adapters are the first layer allowed to touch real chat diff --git a/docs/specification/CHAT_WORKSPACE_MODEL_SPEC.md b/docs/specification/CHAT_WORKSPACE_MODEL_SPEC.md index f8d30c3b..d711a764 100644 --- a/docs/specification/CHAT_WORKSPACE_MODEL_SPEC.md +++ b/docs/specification/CHAT_WORKSPACE_MODEL_SPEC.md @@ -55,6 +55,27 @@ ChatWorkspaceModel::markRead(id) The model forwards actions to `IChatActionSink`. +## Protocol Send Eligibility + +Conversation protocol and active send protocol are separate facts. + +`ConversationId.protocol` identifies what protocol produced or owns the +conversation. The active runtime protocol identifies which transport can send +right now. If they differ, the conversation remains visible and selectable, but +it is read-only for chat commands. + +Required behavior: + +- Read paths must continue to show cross-protocol conversations and messages. +- Compose, reply, retry, and `sendMessage` must be disabled or rejected for + cross-protocol conversations. +- `IChatActionSink` adapters must map `SendMessageView.conversation` back to a + full core `chat::ConversationId`; they must not drop the protocol and send by + bare `channel + peer`. +- Devices that lack a channel creation entry may add one in their renderer, but + renderers that already provide channel selection must not grow a second + duplicate entry. + ## Optimistic Selection `selectConversation(id)` uses optimistic UI selection. diff --git a/modules/core_chat/include/chat/usecase/chat_service.h b/modules/core_chat/include/chat/usecase/chat_service.h index f1b5dcb8..cbcc6269 100644 --- a/modules/core_chat/include/chat/usecase/chat_service.h +++ b/modules/core_chat/include/chat/usecase/chat_service.h @@ -69,6 +69,11 @@ class ChatService NodeId peer = 0); MeshSendResult sendTextWithIdDetailed(ChannelId channel, const std::string& text, MessageId forced_msg_id, NodeId peer = 0); + bool canSendToConversation(const ConversationId& conversation) const; + MessageId sendTextToConversation(const ConversationId& conversation, + const std::string& text); + MeshSendResult sendTextToConversationDetailed(const ConversationId& conversation, + const std::string& text); /** * @brief Trigger protocol discovery action (if supported by active adapter) diff --git a/modules/core_chat/src/usecase/chat_service.cpp b/modules/core_chat/src/usecase/chat_service.cpp index 4012ffd5..9eb3fcc0 100644 --- a/modules/core_chat/src/usecase/chat_service.cpp +++ b/modules/core_chat/src/usecase/chat_service.cpp @@ -56,6 +56,31 @@ MeshSendResult ChatService::sendTextDetailed(ChannelId channel, const std::strin return sendTextWithIdDetailed(channel, text, 0, peer); } +bool ChatService::canSendToConversation(const ConversationId& conversation) const +{ + return conversation.protocol == active_protocol_; +} + +MessageId ChatService::sendTextToConversation(const ConversationId& conversation, + const std::string& text) +{ + const MeshSendResult result = + sendTextToConversationDetailed(conversation, text); + return result.ok ? result.msg_id : 0; +} + +MeshSendResult ChatService::sendTextToConversationDetailed( + const ConversationId& conversation, + const std::string& text) +{ + if (!canSendToConversation(conversation)) + { + return MeshSendResult::fail(MeshOperationFailure::Unsupported); + } + + return sendTextDetailed(conversation.channel, text, conversation.peer); +} + MeshSendResult ChatService::sendTextWithIdDetailed(ChannelId channel, const std::string& text, MessageId forced_msg_id, NodeId peer) { @@ -146,6 +171,10 @@ bool ChatService::resendFailed(MessageId msg_id) { return false; } + if (msg.protocol != active_protocol_) + { + return false; + } const MeshSendResult result = adapter_.sendTextDetailed(msg.channel, msg.text, msg.msg_id, msg.peer); diff --git a/modules/core_chat/tests/test_chat_service_resend.cpp b/modules/core_chat/tests/test_chat_service_resend.cpp index 987e02c9..d441ab7d 100644 --- a/modules/core_chat/tests/test_chat_service_resend.cpp +++ b/modules/core_chat/tests/test_chat_service_resend.cpp @@ -96,6 +96,19 @@ int main() chat::RamStore store; chat::ChatService service(model, mesh, store); + const chat::ConversationId meshcore_conv( + chat::ChannelId::PRIMARY, + 0x11223344, + chat::MeshProtocol::MeshCore); + const chat::ConversationId meshtastic_conv( + chat::ChannelId::PRIMARY, + 0x11223344, + chat::MeshProtocol::Meshtastic); + assert(!service.canSendToConversation(meshcore_conv)); + assert(service.canSendToConversation(meshtastic_conv)); + assert(!service.sendTextToConversationDetailed(meshcore_conv, "wrong proto").ok); + assert(mesh.send_count == 0); + mesh.next_send_ok = false; mesh.next_msg_id = 42; const chat::MeshSendResult failed = @@ -142,5 +155,16 @@ int main() assert(list.back().msg_id == 77); assert(list.back().status == chat::MessageStatus::Failed); + mesh.next_send_ok = false; + mesh.next_msg_id = 88; + const chat::MeshSendResult third_failed = + service.sendTextToConversationDetailed(conv, "proto retry guard"); + assert(!third_failed.ok); + assert(third_failed.msg_id == 88); + const int before_cross_protocol_retry = mesh.send_count; + service.setActiveProtocol(chat::MeshProtocol::MeshCore); + assert(!service.resendFailed(88)); + assert(mesh.send_count == before_cross_protocol_retry); + return 0; } diff --git a/modules/ui_mono/include/ui/mono/runtime.h b/modules/ui_mono/include/ui/mono/runtime.h index f3cef74a..98da40d8 100644 --- a/modules/ui_mono/include/ui/mono/runtime.h +++ b/modules/ui_mono/include/ui/mono/runtime.h @@ -148,6 +148,7 @@ class Runtime : public chat::ChatService::IncomingTextObserver, Sleep, MainMenu, ChatList, + NewChatPage, NodeList, NodeActionMenu, NodeInfo, @@ -182,6 +183,7 @@ class Runtime : public chat::ChatService::IncomingTextObserver, void renderSleep(); void renderMainMenu(); void renderChatList(); + void renderNewChatPage(); void renderNodeList(); void renderNodeActionMenu(); void renderNodeInfo(); @@ -210,6 +212,7 @@ class Runtime : public chat::ChatService::IncomingTextObserver, void buildMessageInfo(); void sendComposeMessage(); void retrySelectedMessage(); + void executeNewChatPageItem(size_t index); void executeDiscoverPageItem(size_t index); void commitConfig(); void ensureBootExit(); @@ -328,6 +331,7 @@ class Runtime : public chat::ChatService::IncomingTextObserver, size_t message_index_ = 0; uint32_t message_focus_started_ms_ = 0; size_t message_menu_index_ = 0; + size_t new_chat_index_ = 0; size_t message_info_scroll_ = 0; size_t gnss_page_index_ = 0; chat::runtime::MeshtasticRuntime meshtastic_protocol_runtime_{}; diff --git a/modules/ui_mono/src/runtime.cpp b/modules/ui_mono/src/runtime.cpp index e0535cbe..9d8bc4ce 100644 --- a/modules/ui_mono/src/runtime.cpp +++ b/modules/ui_mono/src/runtime.cpp @@ -290,6 +290,12 @@ constexpr const char* kMessageMenuItems[] = { "RETRY", }; +constexpr const char* kNewChatItems[] = { + "PRIMARY", + "SECONDARY", + "CANCEL", +}; + constexpr size_t kNodeActionItemCount = 7; constexpr const char* kWeekdays[] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}; @@ -2553,11 +2559,17 @@ void Runtime::handleInput(InputAction action) } case Page::ChatList: + { + const size_t item_count = conversation_count_ + 1U; + if (chat_list_index_ >= item_count) + { + chat_list_index_ = item_count - 1U; + } if (action == InputAction::Up && chat_list_index_ > 0) { --chat_list_index_; } - else if (action == InputAction::Down && chat_list_index_ + 1 < conversation_count_) + else if (action == InputAction::Down && chat_list_index_ + 1 < item_count) { ++chat_list_index_; } @@ -2565,11 +2577,37 @@ void Runtime::handleInput(InputAction action) { enterPage(Page::MainMenu); } - else if ((action == InputAction::Right || action == InputAction::Select || action == InputAction::Primary) && - chat_list_index_ < conversation_count_) + else if (action == InputAction::Right || action == InputAction::Select || action == InputAction::Primary) { - active_conversation_ = conversations_[chat_list_index_].id; - enterPage(Page::Conversation); + if (chat_list_index_ == 0) + { + enterPage(Page::NewChatPage); + } + else if ((chat_list_index_ - 1U) < conversation_count_) + { + active_conversation_ = conversations_[chat_list_index_ - 1U].id; + enterPage(Page::Conversation); + } + } + break; + } + + case Page::NewChatPage: + if (action == InputAction::Up && new_chat_index_ > 0) + { + --new_chat_index_; + } + else if (action == InputAction::Down && new_chat_index_ + 1 < arrayCount(kNewChatItems)) + { + ++new_chat_index_; + } + else if (action == InputAction::Left || action == InputAction::Back) + { + enterPage(Page::ChatList); + } + else if (action == InputAction::Right || action == InputAction::Select || action == InputAction::Primary) + { + executeNewChatPageItem(new_chat_index_); } break; @@ -2686,6 +2724,11 @@ void Runtime::handleInput(InputAction action) } else if (message_menu_index_ == 1) { + if (!app() || !app()->getChatService().canSendToConversation(active_conversation_)) + { + showTransientPopup("MESSAGE", "VIEW ONLY", 2000U); + break; + } openCompose(EditTarget::Message); } else @@ -3158,6 +3201,9 @@ void Runtime::render() case Page::ChatList: renderChatList(); break; + case Page::NewChatPage: + renderNewChatPage(); + break; case Page::NodeList: renderNodeList(); break; @@ -3470,43 +3516,51 @@ void Runtime::renderChatList() { rebuildConversationList(); const size_t page_size = visibleRowsFrom(10); - const size_t total_pages = std::max(1U, (conversation_count_ + page_size - 1U) / page_size); - const size_t selected = (conversation_count_ > 0) - ? std::min(chat_list_index_, conversation_count_ - 1U) - : 0U; - const size_t start = (conversation_count_ > 0) ? ((selected / page_size) * page_size) : 0U; - const size_t current_page = (conversation_count_ > 0) ? ((start / page_size) + 1U) : 1U; + const size_t item_count = conversation_count_ + 1U; + const size_t total_pages = std::max(1U, (item_count + page_size - 1U) / page_size); + const size_t selected = std::min(chat_list_index_, item_count - 1U); + const size_t start = (selected / page_size) * page_size; + const size_t current_page = (start / page_size) + 1U; char pos[16] = {}; std::snprintf(pos, sizeof(pos), "%u/%u", static_cast(current_page), static_cast(total_pages)); drawTitleBar("CHATS", pos); - if (conversation_count_ == 0) - { - text_renderer_.drawText(display_, 0, 18, "NO CONVERSATIONS"); - return; - } const int line_h = text_renderer_.lineHeight(); - const size_t visible = std::min(conversation_count_ - start, page_size); + const size_t visible = std::min(item_count - start, page_size); const int marker_w = std::max(8, text_renderer_.measureTextWidth("T") + 2); const int marker_x = std::max(0, display_.width() - marker_w); const int line_w = std::max(0, marker_x - 1); for (size_t i = 0; i < visible; ++i) { - const size_t conversation_index = start + i; - const bool selected_row = (conversation_index == selected); + const size_t item_index = start + i; + const bool selected_row = (item_index == selected); char line[32] = {}; - const auto& conv = conversations_[conversation_index]; - std::snprintf(line, sizeof(line), "%s%s", - conv.unread > 0 ? "*" : "", - conv.name.c_str()); + const char* marker = app() ? protocolMarker(app()->getConfig().mesh_protocol) : ""; + if (item_index == 0) + { + std::snprintf(line, sizeof(line), "+ NEW CHANNEL"); + } + else + { + const auto& conv = conversations_[item_index - 1U]; + std::snprintf(line, sizeof(line), "%s%s", + conv.unread > 0 ? "*" : "", + conv.name.c_str()); + marker = protocolMarker(conv.id.protocol); + } const int y = 10 + static_cast(i * line_h); drawTextClipped(0, y, line_w, line, selected_row); - drawTextClipped(marker_x, y, marker_w, protocolMarker(conv.id.protocol), selected_row); + drawTextClipped(marker_x, y, marker_w, marker, selected_row); } } +void Runtime::renderNewChatPage() +{ + drawMenuList("NEW CHAT", kNewChatItems, arrayCount(kNewChatItems), new_chat_index_); +} + void Runtime::renderNodeList() { rebuildNodeList(); @@ -5054,7 +5108,11 @@ void Runtime::enterPage(Page page) if (page == Page::ChatList) { rebuildConversationList(); - chat_list_index_ = std::min(chat_list_index_, conversation_count_ == 0 ? 0U : conversation_count_ - 1U); + chat_list_index_ = std::min(chat_list_index_, conversation_count_); + } + else if (page == Page::NewChatPage) + { + new_chat_index_ = std::min(new_chat_index_, arrayCount(kNewChatItems) - 1U); } else if (page == Page::NodeList) { @@ -5686,10 +5744,15 @@ void Runtime::sendComposeMessage() return; } + if (!app()->getChatService().canSendToConversation(active_conversation_)) + { + showTransientPopup("MESSAGE", "VIEW ONLY", 2000U); + return; + } + const chat::MessageId msg_id = - app()->getChatService().sendText(active_conversation_.channel, - compose_buffer_, - active_conversation_.peer); + app()->getChatService().sendTextToConversation(active_conversation_, + compose_buffer_); if (msg_id == 0) { showTransientPopup("MESSAGE", "SEND FAILED", 2000U); @@ -5711,6 +5774,12 @@ void Runtime::retrySelectedMessage() showTransientPopup("MESSAGE", "NOT RETRYABLE", 1800U); return; } + if (!app()->getChatService().canSendToConversation( + chat::ConversationId(msg->channel, msg->peer, msg->protocol))) + { + showTransientPopup("MESSAGE", "VIEW ONLY", 2000U); + return; + } if (!app()->getChatService().resendFailed(msg->msg_id)) { @@ -5723,6 +5792,30 @@ void Runtime::retrySelectedMessage() enterPage(Page::Conversation); } +void Runtime::executeNewChatPageItem(size_t index) +{ + if (index >= arrayCount(kNewChatItems) - 1U) + { + enterPage(Page::ChatList); + return; + } + if (!app()) + { + showTransientPopup("NEW CHAT", "APP NOT READY"); + return; + } + active_conversation_ = chat::ConversationId( + index == 1U ? chat::ChannelId::SECONDARY : chat::ChannelId::PRIMARY, + 0, + app()->getConfig().mesh_protocol); + if (!app()->getChatService().canSendToConversation(active_conversation_)) + { + showTransientPopup("NEW CHAT", "VIEW ONLY"); + return; + } + openCompose(EditTarget::Message); +} + void Runtime::executeDiscoverPageItem(size_t index) { if (index >= arrayCount(kDiscoverItems) - 1U) @@ -7626,12 +7719,17 @@ const char* Runtime::nodeActionLabel(size_t index) const { const chat::contacts::NodeInfo* node = selectedNode(); const bool meshtastic_mode = app() && app()->getConfig().mesh_protocol != chat::MeshProtocol::MeshCore; + const bool can_reply = node && app() && + chat::infra::meshProtocolFromRaw( + static_cast(node->protocol), + app()->getConfig().mesh_protocol) == + app()->getConfig().mesh_protocol; switch (index) { case 0: return "DETAIL"; case 1: - return "REPLY"; + return can_reply ? "REPLY" : "VIEW ONLY"; case 2: return "ADD CONTACT"; case 3: @@ -7668,11 +7766,22 @@ void Runtime::executeNodeAction() return; case 1: { + if (!app()) + { + showTransientPopup("NODE", "APP NOT READY"); + return; + } + const chat::MeshProtocol node_protocol = + chat::infra::meshProtocolFromRaw(static_cast(node->protocol), + app()->getConfig().mesh_protocol); + if (node_protocol != app()->getConfig().mesh_protocol) + { + showTransientPopup("NODE", "VIEW ONLY"); + return; + } active_conversation_ = chat::ConversationId(chat::ChannelId::PRIMARY, node->node_id, - chat::infra::meshProtocolFromRaw( - static_cast(node->protocol), - app()->getConfig().mesh_protocol)); + node_protocol); openCompose(EditTarget::Message); return; } 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 765784e6..75d0938f 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 @@ -218,7 +218,10 @@ bool ChatPresentationSource::buildChatWorkspaceSnapshot( const bool selected_supported = request.selected.kind == ui::chat::ConversationKind::DirectPeer || request.selected.kind == ui::chat::ConversationKind::Channel; - out.can_send = request.selected.isValid() && selected_supported; + out.can_send = request.selected.isValid() && selected_supported && + chat_presentation_adapters::toCoreConversationId(request.selected, + core_selected) && + chat_service_.canSendToConversation(core_selected); out.composer_enabled = out.can_send; return true; } diff --git a/modules/ui_shared/src/ui/presentation_sources/runtime_chat_action_sink.cpp b/modules/ui_shared/src/ui/presentation_sources/runtime_chat_action_sink.cpp index 37e9dc5d..46b39566 100644 --- a/modules/ui_shared/src/ui/presentation_sources/runtime_chat_action_sink.cpp +++ b/modules/ui_shared/src/ui/presentation_sources/runtime_chat_action_sink.cpp @@ -81,7 +81,7 @@ ui::UiActionResult RuntimeChatActionSink::sendMessage( const std::string text(message.text, message.text_len); const ::chat::MeshSendResult result = - chat_service_.sendTextDetailed(core_id.channel, text, core_id.peer); + chat_service_.sendTextToConversationDetailed(core_id, text); if (!result.ok || result.msg_id == 0) { return ui::UiActionResult::fail(mapMeshFailure(result.failure)); diff --git a/modules/ui_shared/tests/test_chat_presentation_source.cpp b/modules/ui_shared/tests/test_chat_presentation_source.cpp index ea2dae0b..989f43bd 100644 --- a/modules/ui_shared/tests/test_chat_presentation_source.cpp +++ b/modules/ui_shared/tests/test_chat_presentation_source.cpp @@ -164,6 +164,21 @@ int main() assert(mesh.last_peer == 1234); assert(mesh.last_text == "hello"); + const ui::chat::ConversationId meshcore_channel = meshCoreBroadcastConversation(); + const int send_count_before_mismatch = mesh.send_count; + const ui::chat::SendMessageView mismatched_send{meshcore_channel, "mc", 2}; + const auto mismatched_result = sink.sendMessage(mismatched_send); + assert(!mismatched_result.ok); + assert(mismatched_result.failure == ui::UiActionFailure::Unsupported); + assert(mesh.send_count == send_count_before_mismatch); + + ui::chat::ChatWorkspaceRequest mismatched_request; + mismatched_request.selected = meshcore_channel; + ui::chat::ChatWorkspaceSnapshot mismatched_snapshot; + assert(source.buildChatWorkspaceSnapshot(mismatched_request, mismatched_snapshot)); + assert(!mismatched_snapshot.can_send); + assert(!mismatched_snapshot.composer_enabled); + ::chat::delivery::ChatDeliveryRecord delivered{}; delivered.ref.protocol_id = 100; delivered.state = ::chat::delivery::DeliveryState::Delivered; @@ -283,6 +298,7 @@ int main() assert(!snapshot.composer_enabled); send.conversation = broadcast; + service.setActiveProtocol(::chat::MeshProtocol::Meshtastic); mesh.send_ok = true; const auto broadcast_send = sink.sendMessage(send); assert(broadcast_send.ok); diff --git a/platform/linux/uconsole/src/uconsole_chat_workspace_model.cpp b/platform/linux/uconsole/src/uconsole_chat_workspace_model.cpp index a5086cc5..2713ef5c 100644 --- a/platform/linux/uconsole/src/uconsole_chat_workspace_model.cpp +++ b/platform/linux/uconsole/src/uconsole_chat_workspace_model.cpp @@ -1283,13 +1283,14 @@ bool UConsoleChatWorkspaceModel::sendText(const std::string& text) } if (!canSendActiveConversation()) { - action_status_ = "No Linux mesh transport is connected."; + action_status_ = services_.chat().canSendToConversation(active_conversation_) + ? "No Linux mesh transport is connected." + : "Conversation is read-only in the active protocol."; return false; } const ::chat::MessageId message_id = - services_.chat().sendText(active_conversation_.channel, trimmed, - active_conversation_.peer); + services_.chat().sendTextToConversation(active_conversation_, trimmed); if (message_id == 0) { action_status_ = "Message failed to queue."; @@ -1629,6 +1630,11 @@ void UConsoleChatWorkspaceModel::ensureActiveConversation() bool UConsoleChatWorkspaceModel::canSendActiveConversation() const { + if (!services_.chat().canSendToConversation(active_conversation_)) + { + return false; + } + const auto* adapter = services_.meshAdapter(); if (adapter == nullptr || !adapter->isReady()) {