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 0d2637a5..cf0a70f4 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 @@ -97,16 +97,33 @@ struct MessageRow TeamMessageRichPayload team_rich_payload; }; +struct ConversationLocationParticipant +{ + uint32_t node_id = 0; + ui::FixedText<32> label; + double lat = 0.0; + double lon = 0.0; + uint32_t timestamp = 0; + bool valid = false; + bool self = false; +}; + struct ChatWorkspaceSnapshot { ui::SnapshotHeader header; + static constexpr size_t kMaxLocationParticipants = 16; + ConversationRow conversations[16]{}; size_t conversation_count = 0; MessageRow messages[24]{}; size_t message_count = 0; + ConversationLocationParticipant location_participants[kMaxLocationParticipants]{}; + size_t location_participant_count = 0; + bool location_participants_truncated = false; + ConversationId selected_conversation; bool can_send = false; @@ -152,6 +169,18 @@ inline void resetMessageRow(MessageRow& row) row.team_rich_payload = TeamMessageRichPayload{}; } +inline void resetConversationLocationParticipant( + ConversationLocationParticipant& participant) +{ + participant.node_id = 0; + participant.label.clear(); + participant.lat = 0.0; + participant.lon = 0.0; + participant.timestamp = 0; + participant.valid = false; + participant.self = false; +} + inline void resetChatWorkspaceSnapshot(ChatWorkspaceSnapshot& out) { out.header = ui::SnapshotHeader{}; @@ -168,6 +197,13 @@ inline void resetChatWorkspaceSnapshot(ChatWorkspaceSnapshot& out) } out.message_count = 0; + for (size_t i = 0; i < ChatWorkspaceSnapshot::kMaxLocationParticipants; ++i) + { + resetConversationLocationParticipant(out.location_participants[i]); + } + out.location_participant_count = 0; + out.location_participants_truncated = false; + out.selected_conversation = ConversationId{}; out.can_send = false; out.composer_enabled = false; diff --git a/modules/ui_shared/include/ui/presentation_sources/chat_presentation_source.h b/modules/ui_shared/include/ui/presentation_sources/chat_presentation_source.h index bcce520f..4c270425 100644 --- a/modules/ui_shared/include/ui/presentation_sources/chat_presentation_source.h +++ b/modules/ui_shared/include/ui/presentation_sources/chat_presentation_source.h @@ -28,7 +28,8 @@ class ChatPresentationSource final : public ui::chat::IChatPresentationSource ChatPresentationSource(::chat::ChatService& chat_service, ::chat::contacts::ContactService* contact_service = nullptr, const ::chat::delivery::ChatDeliveryReadModel* - delivery_read_model = nullptr); + delivery_read_model = nullptr, + const ::chat::IMeshAdapter* mesh_adapter = nullptr); bool buildChatWorkspaceSnapshot( const ui::chat::ChatWorkspaceRequest& request, @@ -38,6 +39,7 @@ class ChatPresentationSource final : public ui::chat::IChatPresentationSource ::chat::ChatService& chat_service_; ::chat::contacts::ContactService* contact_service_ = nullptr; const ::chat::delivery::ChatDeliveryReadModel* delivery_read_model_ = nullptr; + const ::chat::IMeshAdapter* mesh_adapter_ = nullptr; }; } // namespace ui::presentation_sources diff --git a/modules/ui_shared/include/ui/screens/chat/chat_conversation_components.h b/modules/ui_shared/include/ui/screens/chat/chat_conversation_components.h index 3fdb3138..262df3bd 100644 --- a/modules/ui_shared/include/ui/screens/chat/chat_conversation_components.h +++ b/modules/ui_shared/include/ui/screens/chat/chat_conversation_components.h @@ -11,8 +11,10 @@ #include "chat/domain/chat_types.h" #include "chat_conversation_input.h" #include "lvgl.h" +#include "ui/widgets/map/map_viewport.h" #include "ui/widgets/top_bar.h" #include "ui_presentation/chat/chat_message_ref.h" +#include "ui_presentation/map/map_overlay_snapshot.h" #include #include #include @@ -68,6 +70,9 @@ class ChatConversationScreen void setBackCallback(void (*cb)(void*), void* user_data); void setReplyEnabled(bool enabled); bool isReplyEnabled() const { return reply_enabled_; } + void setLocationOverlay(const ::ui::map::MapOverlaySnapshot& overlay); + void toggleLocationMap(); + bool isLocationMapVisible() const { return location_map_visible_; } private: enum class TimerDomain @@ -129,10 +134,16 @@ class ChatConversationScreen lv_obj_t* container_ = nullptr; ::ui::widgets::TopBar top_bar_{}; + lv_obj_t* body_row_ = nullptr; + lv_obj_t* right_column_ = nullptr; lv_obj_t* msg_list_ = nullptr; lv_obj_t* action_bar_ = nullptr; lv_obj_t* reply_btn_ = nullptr; lv_obj_t* compose_btn_ = nullptr; // kept for compatibility (not created in v0) + lv_obj_t* location_panel_ = nullptr; + lv_obj_t* location_map_host_ = nullptr; + ::ui::widgets::map::Runtime location_map_runtime_{}; + ::ui::map::MapOverlaySnapshot location_overlay_{}; chat::ConversationId conv_{}; void (*action_cb_)(ActionIntent intent, void*) = nullptr; @@ -167,10 +178,17 @@ class ChatConversationScreen conversation::input::Binding input_binding_{}; ActionContext reply_ctx_{}; bool reply_enabled_ = true; + bool location_map_visible_ = false; + bool location_map_created_ = false; void createMessageItem(const ::ui::chat::MessageRow& row); void enableRetryAction(MessageItem& item); void disableRetryAction(MessageItem& item); + void createLocationPanel(); + void ensureLocationMapCreated(); + void refreshLocationMap(); + void syncLocationMapVisibility(); + bool usesFloatingLocationMap() const; static void action_event_cb(lv_event_t* e); static void message_action_event_cb(lv_event_t* e); diff --git a/modules/ui_shared/include/ui/screens/chat/chat_conversation_layout.h b/modules/ui_shared/include/ui/screens/chat/chat_conversation_layout.h index 7e3278be..47d25937 100644 --- a/modules/ui_shared/include/ui/screens/chat/chat_conversation_layout.h +++ b/modules/ui_shared/include/ui/screens/chat/chat_conversation_layout.h @@ -10,6 +10,8 @@ struct ConversationWidgets { lv_obj_t* root = nullptr; + lv_obj_t* body_row = nullptr; + lv_obj_t* right_column = nullptr; lv_obj_t* msg_list = nullptr; lv_obj_t* action_bar = nullptr; lv_obj_t* reply_btn = nullptr; @@ -17,7 +19,8 @@ struct ConversationWidgets }; /** - * Root(Column): TopBar(widget) + MsgList(grow=1) + ActionBar(fixed height) + * Root(Column): TopBar(widget) + BodyRow(grow=1) + * BodyRow: optional LocationPanel + RightColumn(MsgList + ActionBar) */ ConversationWidgets create_conversation_base(lv_obj_t* parent); 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 75d0938f..e8667dc4 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 @@ -5,6 +5,7 @@ #include "chat_presentation_adapters/chat_message_mapper.h" #include "ui_presentation/common/fixed_text.h" +#include #include #include @@ -15,6 +16,7 @@ namespace constexpr std::size_t kMaxConversationRows = 16; constexpr std::size_t kMaxMessageRows = 24; +constexpr double kCoordinateScale = 10000000.0; template void copyString(ui::FixedText& out, const std::string& text) @@ -36,6 +38,193 @@ void copyNodeLabel(ui::FixedText<32>& out, ::chat::NodeId node_id) ui::copyText(out, buffer); } +bool isValidCoordinate(double lat, double lon) +{ + return std::isfinite(lat) && std::isfinite(lon) && + lat >= -90.0 && lat <= 90.0 && + lon >= -180.0 && lon <= 180.0; +} + +void copyParticipantLabel(ui::FixedText<32>& out, + const ::chat::contacts::ContactService* contacts, + ::chat::NodeId node_id, + bool self) +{ + if (self) + { + ui::copyText(out, "Me"); + return; + } + if (contacts != nullptr) + { + const std::string contact = contacts->getContactName(node_id); + if (!contact.empty()) + { + ui::copyText(out, contact.c_str()); + return; + } + } + copyNodeLabel(out, node_id); +} + +bool nodePositionToLocation(const ::chat::contacts::ContactService* contacts, + ::chat::NodeId node_id, + double& out_lat, + double& out_lon, + uint32_t& out_timestamp) +{ + if (contacts == nullptr || node_id == 0) + { + return false; + } + + const auto* node = contacts->getNodeInfo(node_id); + if (node == nullptr || !node->position.valid) + { + return false; + } + + const double lat = static_cast(node->position.latitude_i) / kCoordinateScale; + const double lon = static_cast(node->position.longitude_i) / kCoordinateScale; + if (!isValidCoordinate(lat, lon)) + { + return false; + } + + out_lat = lat; + out_lon = lon; + out_timestamp = node->position.timestamp; + return true; +} + +bool messageGeoToLocation(const ::chat::ChatMessage& message, + double& out_lat, + double& out_lon, + uint32_t& out_timestamp) +{ + if (!message.has_geo) + { + return false; + } + + const double lat = static_cast(message.geo_lat_e7) / kCoordinateScale; + const double lon = static_cast(message.geo_lon_e7) / kCoordinateScale; + if (!isValidCoordinate(lat, lon)) + { + return false; + } + + out_lat = lat; + out_lon = lon; + out_timestamp = message.timestamp; + return true; +} + +::ui::chat::ConversationLocationParticipant* findLocationParticipant( + ::ui::chat::ChatWorkspaceSnapshot& snapshot, + ::chat::NodeId node_id) +{ + if (node_id == 0) + { + return nullptr; + } + + for (std::size_t i = 0; i < snapshot.location_participant_count; ++i) + { + auto& participant = snapshot.location_participants[i]; + if (participant.node_id == node_id) + { + return &participant; + } + } + return nullptr; +} + +void appendLocationParticipant(::ui::chat::ChatWorkspaceSnapshot& snapshot, + const ::chat::contacts::ContactService* contacts, + ::chat::NodeId node_id, + double lat, + double lon, + uint32_t timestamp, + bool self) +{ + if (node_id == 0 || !isValidCoordinate(lat, lon)) + { + return; + } + + if (auto* existing = findLocationParticipant(snapshot, node_id)) + { + existing->lat = lat; + existing->lon = lon; + existing->timestamp = timestamp; + existing->valid = true; + existing->self = existing->self || self; + if (existing->label.empty()) + { + copyParticipantLabel(existing->label, contacts, node_id, existing->self); + } + return; + } + + if (snapshot.location_participant_count >= + ::ui::chat::ChatWorkspaceSnapshot::kMaxLocationParticipants) + { + snapshot.location_participants_truncated = true; + return; + } + + auto& participant = + snapshot.location_participants[snapshot.location_participant_count++]; + participant.node_id = node_id; + participant.lat = lat; + participant.lon = lon; + participant.timestamp = timestamp; + participant.valid = true; + participant.self = self; + copyParticipantLabel(participant.label, contacts, node_id, self); +} + +void appendNodePositionParticipant( + ::ui::chat::ChatWorkspaceSnapshot& snapshot, + const ::chat::contacts::ContactService* contacts, + ::chat::NodeId node_id, + bool self) +{ + double lat = 0.0; + double lon = 0.0; + uint32_t timestamp = 0; + if (!nodePositionToLocation(contacts, node_id, lat, lon, timestamp)) + { + return; + } + appendLocationParticipant(snapshot, contacts, node_id, lat, lon, timestamp, self); +} + +void appendMessageLocationParticipant( + ::ui::chat::ChatWorkspaceSnapshot& snapshot, + const ::chat::contacts::ContactService* contacts, + const ::chat::ChatMessage& message, + ::chat::NodeId self_node) +{ + const bool outgoing = message.status != ::chat::MessageStatus::Incoming; + const ::chat::NodeId node_id = outgoing ? self_node : message.from; + if (node_id == 0 || findLocationParticipant(snapshot, node_id) != nullptr) + { + return; + } + + double lat = 0.0; + double lon = 0.0; + uint32_t timestamp = 0; + if (nodePositionToLocation(contacts, node_id, lat, lon, timestamp) || + messageGeoToLocation(message, lat, lon, timestamp)) + { + appendLocationParticipant( + snapshot, contacts, node_id, lat, lon, timestamp, outgoing); + } +} + void copyTimeLabel(ui::FixedText<24>& out, uint32_t timestamp) { if (timestamp == 0) @@ -105,10 +294,12 @@ ui::chat::MessageFailureKind mapDeliveryFailure( ChatPresentationSource::ChatPresentationSource( ::chat::ChatService& chat_service, ::chat::contacts::ContactService* contact_service, - const ::chat::delivery::ChatDeliveryReadModel* delivery_read_model) + const ::chat::delivery::ChatDeliveryReadModel* delivery_read_model, + const ::chat::IMeshAdapter* mesh_adapter) : chat_service_(chat_service), contact_service_(contact_service), - delivery_read_model_(delivery_read_model) + delivery_read_model_(delivery_read_model), + mesh_adapter_(mesh_adapter) { } @@ -157,6 +348,18 @@ bool ChatPresentationSource::buildChatWorkspaceSnapshot( { const auto messages = chat_service_.getRecentMessages(core_selected, kMaxMessageRows); + const ::chat::NodeId self_node = + mesh_adapter_ != nullptr ? mesh_adapter_->getNodeId() : 0; + if (self_node != 0) + { + appendNodePositionParticipant( + out, contact_service_, self_node, true); + } + if (core_selected.peer != 0) + { + appendNodePositionParticipant( + out, contact_service_, core_selected.peer, false); + } out.message_count = messages.size() < kMaxMessageRows ? messages.size() : kMaxMessageRows; @@ -212,6 +415,8 @@ bool ChatPresentationSource::buildChatWorkspaceSnapshot( { copyNodeLabel(row.sender_label, message.from); } + appendMessageLocationParticipant( + out, contact_service_, message, self_node); } } 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 90f492cf..db02d45a 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 @@ -2,9 +2,12 @@ #include "platform/ui/team_ui_chat_log_store.h" #include "platform/ui/team_ui_snapshot_store.h" +#include "platform/ui/team_ui_store_runtime.h" +#include "ui/team_presentation/team_member_label.h" #include "ui/team_presentation/team_rich_payload_projector.h" #include "ui_presentation/common/fixed_text.h" +#include #include #include #include @@ -16,6 +19,7 @@ namespace { constexpr std::size_t kMaxMessageRows = 24; +constexpr double kCoordinateScale = 10000000.0; uint32_t foldTeamId(const ::team::TeamId& team_id) { @@ -84,6 +88,66 @@ void copySenderLabel(ui::FixedText<32>& out, ui::copyText(out, buffer); } +bool hasCoordinate(const ::team::ui::TeamPosSample& sample) +{ + return sample.lat_e7 != 0 || sample.lon_e7 != 0; +} + +bool validCoordinate(double lat, double lon) +{ + return std::isfinite(lat) && std::isfinite(lon) && lat >= -90.0 && + lat <= 90.0 && lon >= -180.0 && lon <= 180.0; +} + +void appendTeamLocationParticipants(ui::chat::ChatWorkspaceSnapshot& out, + const ::team::ui::TeamUiSnapshot& snap) +{ + if (!snap.in_team || !snap.has_team_id) + { + return; + } + + std::vector<::team::ui::TeamPosSample> samples; + if (!::team::ui::team_ui_posring_load_latest(snap.team_id, samples)) + { + return; + } + + for (const auto& sample : samples) + { + if (!hasCoordinate(sample)) + { + continue; + } + + const double lat = static_cast(sample.lat_e7) / kCoordinateScale; + const double lon = static_cast(sample.lon_e7) / kCoordinateScale; + if (!validCoordinate(lat, lon)) + { + continue; + } + + if (out.location_participant_count >= + ui::chat::ChatWorkspaceSnapshot::kMaxLocationParticipants) + { + out.location_participants_truncated = true; + break; + } + + auto& participant = + out.location_participants[out.location_participant_count++]; + participant.node_id = sample.member_id; + participant.lat = lat; + participant.lon = lon; + participant.timestamp = sample.ts; + participant.valid = true; + participant.self = false; + const std::string label = + ::ui::team_presentation::shortTeamMemberLabel(sample.member_id); + ui::copyText(participant.label, label.c_str()); + } +} + uint64_t messageLocalId(const ::team::ui::TeamChatLogEntry& entry, std::size_t index) { @@ -227,6 +291,7 @@ bool TeamChatPresentationSource::buildChatWorkspaceSnapshot( out.can_send = snap.has_team_psk; out.composer_enabled = out.can_send; + appendTeamLocationParticipants(out, snap); out.message_count = entries.size() < kMaxMessageRows ? entries.size() : kMaxMessageRows; 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 1807469e..d33a8598 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 @@ -21,8 +21,10 @@ #include #include +#include #include #include +#include #include #ifndef CHAT_CONVERSATION_LOG_ENABLE @@ -50,12 +52,134 @@ constexpr uint32_t kSecondsPerMonth = 30U * kSecondsPerDay; constexpr uint32_t kSecondsPerYear = 365U * kSecondsPerDay; constexpr uint32_t kMinValidEpochSeconds = 1577836800U; // 2020-01-01 constexpr size_t kMaxPrefixedSenderLen = 20; +constexpr lv_coord_t kLocationMapBorderPx = 2; +constexpr lv_coord_t kLocationMapInnerSize = 188; +constexpr lv_coord_t kLocationMapOuterSize = + kLocationMapInnerSize + (kLocationMapBorderPx * 2); +constexpr lv_coord_t kLocationMapFitPadding = 14; lv_coord_t bubble_pad_x() { return ::ui::page_profile::is_dense() ? 6 : kBubblePadX; } +void make_plain(lv_obj_t* obj) +{ + if (!obj) + { + return; + } + lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_scrollbar_mode(obj, LV_SCROLLBAR_MODE_OFF); + lv_obj_set_style_pad_all(obj, 0, 0); + lv_obj_set_style_border_width(obj, 0, 0); + lv_obj_set_style_radius(obj, 0, 0); + lv_obj_set_style_bg_opa(obj, LV_OPA_TRANSP, 0); +} + +void set_hidden(lv_obj_t* obj, bool hidden) +{ + if (!obj || !lv_obj_is_valid(obj)) + { + return; + } + if (hidden) + { + lv_obj_add_flag(obj, LV_OBJ_FLAG_HIDDEN); + } + else + { + lv_obj_clear_flag(obj, LV_OBJ_FLAG_HIDDEN); + } +} + +bool overlay_item_has_valid_point(const ::ui::map::MapOverlayItem& item) +{ + return item.visible && item.point.valid && + std::isfinite(item.point.lat) && + std::isfinite(item.point.lon) && + item.point.lat >= -90.0 && item.point.lat <= 90.0 && + item.point.lon >= -180.0 && item.point.lon <= 180.0; +} + +bool location_overlay_bounds(const ::ui::map::MapOverlaySnapshot& overlay, + double& out_min_lat, + double& out_max_lat, + double& out_min_lon, + double& out_max_lon, + std::size_t& out_count) +{ + out_min_lat = 90.0; + out_max_lat = -90.0; + out_min_lon = 180.0; + out_max_lon = -180.0; + out_count = 0; + + if (!overlay.header.valid) + { + return false; + } + + for (std::size_t i = 0; i < overlay.item_count; ++i) + { + const auto& item = overlay.items[i]; + if (!overlay_item_has_valid_point(item)) + { + continue; + } + out_min_lat = std::min(out_min_lat, item.point.lat); + out_max_lat = std::max(out_max_lat, item.point.lat); + out_min_lon = std::min(out_min_lon, item.point.lon); + out_max_lon = std::max(out_max_lon, item.point.lon); + ++out_count; + } + + return out_count > 0; +} + +bool model_fits_location_overlay(lv_obj_t* viewport_root, + const ::ui::widgets::map::Model& model, + const ::ui::map::MapOverlaySnapshot& overlay) +{ + if (!viewport_root || !lv_obj_is_valid(viewport_root)) + { + return false; + } + + lv_obj_update_layout(viewport_root); + for (std::size_t i = 0; i < overlay.item_count; ++i) + { + const auto& item = overlay.items[i]; + if (!overlay_item_has_valid_point(item)) + { + continue; + } + + lv_point_t point{}; + const ::ui::widgets::map::GeoPoint geo{ + true, + item.point.lat, + item.point.lon}; + if (!::ui::widgets::map::preview_project_point( + viewport_root, + model, + geo, + point)) + { + return false; + } + + if (point.x < kLocationMapFitPadding || + point.y < kLocationMapFitPadding || + point.x > kLocationMapInnerSize - kLocationMapFitPadding || + point.y > kLocationMapInnerSize - kLocationMapFitPadding) + { + return false; + } + } + return true; +} + ::ui::chat::MessageDeliveryState delivery_from_message_status( chat::MessageStatus status) { @@ -289,6 +413,8 @@ ChatConversationScreen::ChatConversationScreen(lv_obj_t* parent, chat::Conversat // ----- Layout ----- auto w = chat::ui::layout::create_conversation_base(parent); container_ = w.root; + body_row_ = w.body_row; + right_column_ = w.right_column; msg_list_ = w.msg_list; action_bar_ = w.action_bar; reply_btn_ = w.reply_btn; @@ -340,6 +466,8 @@ ChatConversationScreen::ChatConversationScreen(lv_obj_t* parent, chat::Conversat ChatConversationScreen::~ChatConversationScreen() { + ::ui::widgets::map::destroy(location_map_runtime_); + location_map_created_ = false; if (container_ && lv_obj_is_valid(container_)) { lv_obj_del(container_); @@ -514,6 +642,218 @@ void ChatConversationScreen::setReplyEnabled(bool enabled) } } +void ChatConversationScreen::setLocationOverlay( + const ::ui::map::MapOverlaySnapshot& overlay) +{ + location_overlay_ = overlay; + if (location_map_visible_) + { + refreshLocationMap(); + } +} + +void ChatConversationScreen::toggleLocationMap() +{ + if (!guard_ || !guard_->alive) + { + return; + } + + location_map_visible_ = !location_map_visible_; + syncLocationMapVisibility(); +} + +bool ChatConversationScreen::usesFloatingLocationMap() const +{ +#if defined(ARDUINO_T_DECK) || defined(ARDUINO_T_DECK_PRO) + return true; +#else + const auto& profile = ::ui::page_profile::current(); + return profile.name != nullptr && std::strcmp(profile.name, "tdeck") == 0; +#endif +} + +void ChatConversationScreen::createLocationPanel() +{ + if (location_panel_ && lv_obj_is_valid(location_panel_)) + { + return; + } + if (!container_ || !lv_obj_is_valid(container_)) + { + return; + } + + const bool floating = usesFloatingLocationMap(); + lv_obj_t* parent = floating ? container_ : body_row_; + if (!parent || !lv_obj_is_valid(parent)) + { + return; + } + + location_panel_ = lv_obj_create(parent); + lv_obj_set_size(location_panel_, + kLocationMapOuterSize, + kLocationMapOuterSize); + lv_obj_clear_flag(location_panel_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_scrollbar_mode(location_panel_, LV_SCROLLBAR_MODE_OFF); + lv_obj_set_style_pad_all(location_panel_, 0, 0); + lv_obj_set_style_radius(location_panel_, 0, 0); + lv_obj_set_style_bg_color(location_panel_, lv_color_hex(0x111827), 0); + lv_obj_set_style_bg_opa(location_panel_, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(location_panel_, kLocationMapBorderPx, 0); + lv_obj_set_style_border_color(location_panel_, lv_color_hex(0xF8FAFC), 0); + lv_obj_set_style_border_opa(location_panel_, LV_OPA_COVER, 0); + lv_obj_set_flex_grow(location_panel_, 0); + lv_obj_add_flag(location_panel_, LV_OBJ_FLAG_HIDDEN); + + if (floating) + { +#ifdef LV_OBJ_FLAG_IGNORE_LAYOUT + lv_obj_add_flag(location_panel_, LV_OBJ_FLAG_IGNORE_LAYOUT); +#endif + lv_obj_align(location_panel_, + LV_ALIGN_TOP_LEFT, + kLocationMapBorderPx, + static_cast( + ::ui::page_profile::current().top_bar_height + + kLocationMapBorderPx)); + } + else + { + lv_obj_move_to_index(location_panel_, 0); + } + + location_map_host_ = lv_obj_create(location_panel_); + make_plain(location_map_host_); + lv_obj_set_size(location_map_host_, + kLocationMapInnerSize, + kLocationMapInnerSize); + lv_obj_center(location_map_host_); +} + +void ChatConversationScreen::ensureLocationMapCreated() +{ + createLocationPanel(); + if (location_map_created_ || + !location_map_host_ || + !lv_obj_is_valid(location_map_host_)) + { + return; + } + + (void)::ui::widgets::map::create( + location_map_runtime_, + location_map_host_, + 75); + ::ui::widgets::map::set_size( + location_map_runtime_, + kLocationMapInnerSize, + kLocationMapInnerSize); + ::ui::widgets::map::set_gesture_enabled(location_map_runtime_, false); + location_map_created_ = true; +} + +void ChatConversationScreen::refreshLocationMap() +{ + if (!location_map_visible_) + { + return; + } + + ensureLocationMapCreated(); + if (!location_map_created_ || + !location_map_host_ || + !lv_obj_is_valid(location_map_host_)) + { + return; + } + + double min_lat = 0.0; + double max_lat = 0.0; + double min_lon = 0.0; + double max_lon = 0.0; + std::size_t point_count = 0; + if (!location_overlay_bounds(location_overlay_, + min_lat, + max_lat, + min_lon, + max_lon, + point_count)) + { + ::ui::widgets::map::clear(location_map_runtime_); + return; + } + + ::ui::widgets::map::Model model{}; + const auto layers = ::ui::widgets::map::current_layer_state(); + model.focus_point.valid = true; + model.focus_point.lat = (min_lat + max_lat) / 2.0; + model.focus_point.lon = (min_lon + max_lon) / 2.0; + model.map_source = layers.map_source; + model.contour_enabled = layers.contour_enabled; + model.coord_system = app::configFacade().getConfig().map_coord_system; + + if (point_count <= 1) + { + model.zoom = ::ui::widgets::map::kDefaultZoom; + } + else + { + model.zoom = ::ui::widgets::map::kMinZoom; + for (int zoom = ::ui::widgets::map::kMaxZoom; + zoom >= ::ui::widgets::map::kMinZoom; + --zoom) + { + model.zoom = zoom; + if (model_fits_location_overlay( + location_map_host_, + model, + location_overlay_)) + { + break; + } + } + } + + ::ui::widgets::map::apply_model(location_map_runtime_, model); + ::ui::widgets::map::apply_overlay(location_map_runtime_, location_overlay_); +} + +void ChatConversationScreen::syncLocationMapVisibility() +{ + if (!location_map_visible_) + { + set_hidden(location_panel_, true); + if (location_map_created_) + { + ::ui::widgets::map::clear(location_map_runtime_); + } + return; + } + + ensureLocationMapCreated(); + set_hidden(location_panel_, false); + if (location_panel_ && lv_obj_is_valid(location_panel_)) + { + if (usesFloatingLocationMap()) + { + lv_obj_align(location_panel_, + LV_ALIGN_TOP_LEFT, + kLocationMapBorderPx, + static_cast( + ::ui::page_profile::current().top_bar_height + + kLocationMapBorderPx)); + lv_obj_move_foreground(location_panel_); + } + else + { + lv_obj_move_to_index(location_panel_, 0); + } + } + refreshLocationMap(); +} + void ChatConversationScreen::createMessageItem(const ::ui::chat::MessageRow& row) { if (!guard_ || !guard_->alive || !msg_list_) @@ -928,6 +1268,8 @@ void ChatConversationScreen::handle_root_deleted() reply_ctx_.screen = nullptr; chat::ui::conversation::input::cleanup(&input_binding_); + ::ui::widgets::map::destroy(location_map_runtime_); + location_map_created_ = false; clear_all_timers(); if (top_bar_.back_btn) @@ -936,10 +1278,15 @@ void ChatConversationScreen::handle_root_deleted() } container_ = nullptr; + body_row_ = nullptr; + right_column_ = nullptr; msg_list_ = nullptr; action_bar_ = nullptr; reply_btn_ = nullptr; compose_btn_ = nullptr; + location_panel_ = nullptr; + location_map_host_ = nullptr; + location_map_visible_ = false; } void ChatConversationScreen::schedule_action_async(ActionIntent intent) diff --git a/modules/ui_shared/src/ui/screens/chat/chat_conversation_input.cpp b/modules/ui_shared/src/ui/screens/chat/chat_conversation_input.cpp index a77c3ca3..ddaa6245 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_conversation_input.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_conversation_input.cpp @@ -21,6 +21,24 @@ constexpr int kEncoderKeyRotateDown = 20; constexpr lv_coord_t kScrollStep = 24; } // namespace +static bool handle_location_key(ChatConversationScreen* screen, + lv_event_t* e, + uint32_t key) +{ + if (key != 'l' && key != 'L') + { + return false; + } + if (!screen || !screen->isAlive()) + { + return false; + } + + screen->toggleLocationMap(); + lv_event_stop_processing(e); + return true; +} + static void on_msg_list_key(lv_event_t* e) { auto* screen = static_cast(lv_event_get_user_data(e)); @@ -37,6 +55,11 @@ static void on_msg_list_key(lv_event_t* e) return; } + if (handle_location_key(screen, e, key)) + { + return; + } + if (lv_group_t* g = lv_group_get_default()) { if (key == LV_KEY_ENTER) @@ -76,6 +99,10 @@ static void on_backspace_key(lv_event_t* e) auto* screen = static_cast(lv_event_get_user_data(e)); if (!screen || !screen->isAlive()) return; uint32_t key = lv_event_get_key(e); + if (handle_location_key(screen, e, key)) + { + return; + } if (key != LV_KEY_BACKSPACE && key != LV_KEY_ESC) return; if (lv_obj_t* back_btn = screen->getBackBtn()) { diff --git a/modules/ui_shared/src/ui/screens/chat/chat_conversation_layout.cpp b/modules/ui_shared/src/ui/screens/chat/chat_conversation_layout.cpp index 020ef495..fc2924d1 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_conversation_layout.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_conversation_layout.cpp @@ -14,27 +14,23 @@ * | | < Back (Title) (Status/...) | | * | +------------------------------------------------------------+ | * | | - * | Msg List (scrollable V, flex-grow = 1) | + * | BodyRow (ROW, flex-grow = 1) | * | +------------------------------------------------------------+ | - * | | Row (full width, transparent) | | - * | | + Bubble (max ~70% width) | | - * | | + TextLabel (WRAP) | | - * | | self -> row align END / other -> row align START | | - * | +------------------------------------------------------------+ | - * | | - * | Action Bar (fixed height = 30, non-scrollable) | - * | +------------------------------------------------------------+ | - * | | [ Send ] | | + * | | Optional location map panel + RightColumn | | + * | | RightColumn: MsgList(grow=1) + ActionBar(fixed height) | | * | +------------------------------------------------------------+ | * +----------------------------------------------------------------+ * * Tree view: * Root(COL) * - TopBar(widget) // created by top_bar_init(top_bar_, root) - * - MsgList(COL, scroll V, grow=1) - * - MsgRow*(repeat, ROW, full) - * - Bubble(COL, content) -> TextLabel(WRAP) - * - ActionBar(ROW, fixed=30) -> ComposeBtn -> ComposeLabel + * - BodyRow(ROW, grow=1) + * - LocationPanel(optional) + * - RightColumn(COL, grow=1) + * - MsgList(COL, scroll V, grow=1) + * - MsgRow*(repeat, ROW, full) + * - Bubble(COL, content) -> TextLabel(WRAP) + * - ActionBar(ROW, fixed=30) -> ComposeBtn -> ComposeLabel * * Notes: * - Structure/layout only: create objects, set size/flex/align/flags. @@ -53,6 +49,15 @@ static void make_non_scrollable(lv_obj_t* obj) lv_obj_set_scrollbar_mode(obj, LV_SCROLLBAR_MODE_OFF); } +static void make_plain_container(lv_obj_t* obj) +{ + make_non_scrollable(obj); + lv_obj_set_style_pad_all(obj, 0, 0); + lv_obj_set_style_border_width(obj, 0, 0); + lv_obj_set_style_radius(obj, 0, 0); + lv_obj_set_style_bg_opa(obj, LV_OPA_TRANSP, 0); +} + ConversationWidgets create_conversation_base(lv_obj_t* parent) { ConversationWidgets w{}; @@ -65,8 +70,25 @@ ConversationWidgets create_conversation_base(lv_obj_t* parent) lv_obj_set_style_pad_row(w.root, 0, 0); make_non_scrollable(w.root); + // Body row holds the optional location panel plus the message/actions column. + w.body_row = lv_obj_create(w.root); + lv_obj_set_width(w.body_row, LV_PCT(100)); + lv_obj_set_flex_grow(w.body_row, 1); + lv_obj_set_flex_flow(w.body_row, LV_FLEX_FLOW_ROW); + lv_obj_set_style_pad_column(w.body_row, 0, 0); + lv_obj_set_style_pad_row(w.body_row, 0, 0); + make_plain_container(w.body_row); + + w.right_column = lv_obj_create(w.body_row); + lv_obj_set_width(w.right_column, 1); + lv_obj_set_height(w.right_column, LV_PCT(100)); + lv_obj_set_flex_grow(w.right_column, 1); + lv_obj_set_flex_flow(w.right_column, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(w.right_column, 0, 0); + make_plain_container(w.right_column); + // Msg list (scrollable, grow=1) - w.msg_list = lv_obj_create(w.root); + w.msg_list = lv_obj_create(w.right_column); lv_obj_set_width(w.msg_list, LV_PCT(100)); lv_obj_set_flex_grow(w.msg_list, 1); lv_obj_set_flex_flow(w.msg_list, LV_FLEX_FLOW_COLUMN); @@ -76,7 +98,7 @@ ConversationWidgets create_conversation_base(lv_obj_t* parent) lv_obj_set_scrollbar_mode(w.msg_list, LV_SCROLLBAR_MODE_OFF); // Action bar (fixed height) - w.action_bar = lv_obj_create(w.root); + w.action_bar = lv_obj_create(w.right_column); lv_obj_set_size(w.action_bar, LV_PCT(100), ::ui::page_profile::resolve_control_button_height() + (profile.dense ? 2 : 2)); diff --git a/modules/ui_shared/src/ui/screens/chat/chat_page_runtime.cpp b/modules/ui_shared/src/ui/screens/chat/chat_page_runtime.cpp index 04ee6882..86460b4e 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_page_runtime.cpp @@ -288,7 +288,8 @@ void enter(const shell::Host* host, lv_obj_t* parent) new ::ui::presentation_sources::ChatPresentationSource( chat_service, &app::messagingFacade().getContactService(), - s_delivery_read_model.get())); + s_delivery_read_model.get(), + app::messagingFacade().getMeshAdapter())); s_chat_sink = std::unique_ptr<::ui::presentation_sources::RuntimeChatActionSink>( new ::ui::presentation_sources::RuntimeChatActionSink(chat_service)); s_chat_model = std::unique_ptr<::ui::chat::ChatWorkspaceModel>( 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 62abf102..19dd7a2d 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 @@ -24,6 +24,7 @@ #include "ui_lvgl_ux_packs/common/key_verification_modal_renderer.h" #include "ui_lvgl_ux_packs/common/team_position_picker_renderer.h" #include "ui_presentation/key_verification/key_verification_model.h" +#include "ui_presentation/map/map_overlay_snapshot.h" #include #include #include @@ -163,6 +164,47 @@ bool teamConversationMetaFromSnapshot( return false; } +::ui::map::MapOverlaySnapshot buildConversationLocationOverlay( + const ::ui::chat::ChatWorkspaceSnapshot& snapshot) +{ + ::ui::map::MapOverlaySnapshot overlay{}; + overlay.header.valid = true; + overlay.header.version = 1; + + for (size_t i = 0; i < snapshot.location_participant_count; ++i) + { + if (overlay.item_count >= ::ui::map::MapOverlaySnapshot::kMaxItems) + { + overlay.truncated = true; + break; + } + + const auto& participant = snapshot.location_participants[i]; + if (!participant.valid) + { + continue; + } + + auto& item = overlay.items[overlay.item_count++]; + item.kind = participant.self + ? ::ui::map::MapOverlayKind::CurrentPosition + : ::ui::map::MapOverlayKind::TeamMember; + item.style = participant.self + ? ::ui::map::MapOverlayStyle::OwnPosition + : ::ui::map::MapOverlayStyle::Team; + item.point.valid = true; + item.point.lat = participant.lat; + item.point.lon = participant.lon; + item.stable_id = participant.node_id; + item.visible = true; + ::ui::copyText(item.label, participant.label.c_str()); + } + + overlay.truncated = + overlay.truncated || snapshot.location_participants_truncated; + return overlay; +} + void applySnapshotMessagesToConversation( const ::ui::chat::ChatWorkspaceSnapshot& snapshot, ChatConversationScreen& conversation) @@ -172,6 +214,7 @@ void applySnapshotMessagesToConversation( { conversation.addMessage(snapshot.messages[i]); } + conversation.setLocationOverlay(buildConversationLocationOverlay(snapshot)); conversation.scrollToBottom(); } diff --git a/modules/ui_shared/tests/test_chat_presentation_source.cpp b/modules/ui_shared/tests/test_chat_presentation_source.cpp index 513c62c7..65b9a08e 100644 --- a/modules/ui_shared/tests/test_chat_presentation_source.cpp +++ b/modules/ui_shared/tests/test_chat_presentation_source.cpp @@ -4,15 +4,18 @@ #include "chat/infra/store/ram_store.h" #include "chat/ports/i_mesh_adapter.h" #include "chat/usecase/chat_service.h" +#include "chat/usecase/contact_service.h" #include "sys/clock.h" #include "ui/presentation_sources/chat_presentation_source.h" #include "ui/presentation_sources/runtime_chat_action_sink.h" #include +#include #include #include #include #include +#include namespace { @@ -75,6 +78,7 @@ class FakeMeshAdapter final : public ::chat::IMeshAdapter void applyConfig(const ::chat::MeshConfig&) override {} bool isReady() const override { return true; } bool pollIncomingRawPacket(uint8_t*, size_t&, size_t) override { return false; } + ::chat::NodeId getNodeId() const override { return self_node_id; } int send_count = 0; bool send_ok = true; @@ -84,9 +88,140 @@ class FakeMeshAdapter final : public ::chat::IMeshAdapter ::chat::ChannelId last_channel = ::chat::ChannelId::PRIMARY; std::string last_text; ::chat::NodeId last_peer = 0; + ::chat::NodeId self_node_id = 0; std::deque<::chat::MeshIncomingText> incoming; }; +class FakeNodeStore final : public ::chat::contacts::INodeStore +{ + public: + void begin() override {} + + void applyUpdate(uint32_t node_id, + const ::chat::contacts::NodeUpdate& update) override + { + auto* entry = findOrCreate(node_id); + if (update.short_name != nullptr) + { + std::snprintf(entry->short_name, + sizeof(entry->short_name), + "%s", + update.short_name); + } + if (update.long_name != nullptr) + { + std::snprintf(entry->long_name, + sizeof(entry->long_name), + "%s", + update.long_name); + } + if (update.has_last_seen) + { + entry->last_seen = update.last_seen; + } + if (update.has_position) + { + updatePosition(node_id, update.position); + } + } + + void upsert(uint32_t node_id, + const char* short_name, + const char* long_name, + uint32_t now_secs, + float snr = 0.0f, + float rssi = 0.0f, + uint8_t protocol = 0, + uint8_t role = ::chat::contacts::kNodeRoleUnknown, + uint8_t hops_away = 0xFF, + uint8_t hw_model = 0, + uint8_t channel = 0xFF) override + { + (void)snr; + (void)rssi; + (void)protocol; + (void)role; + (void)hops_away; + (void)hw_model; + (void)channel; + auto* entry = findOrCreate(node_id); + std::snprintf(entry->short_name, + sizeof(entry->short_name), + "%s", + short_name ? short_name : ""); + std::snprintf(entry->long_name, + sizeof(entry->long_name), + "%s", + long_name ? long_name : ""); + entry->last_seen = now_secs; + } + + void updateProtocol(uint32_t, uint8_t, uint32_t) override {} + + void updatePosition(uint32_t node_id, + const ::chat::contacts::NodePosition& position) override + { + auto* entry = findOrCreate(node_id); + entry->position_valid = position.valid; + entry->position_latitude_i = position.latitude_i; + entry->position_longitude_i = position.longitude_i; + entry->position_has_altitude = position.has_altitude; + entry->position_altitude = position.altitude; + entry->position_timestamp = position.timestamp; + } + + bool remove(uint32_t node_id) override + { + for (auto it = entries.begin(); it != entries.end(); ++it) + { + if (it->node_id == node_id) + { + entries.erase(it); + return true; + } + } + return false; + } + + const std::vector<::chat::contacts::NodeEntry>& getEntries() const override + { + return entries; + } + + void clear() override { entries.clear(); } + bool flush() override { return true; } + + private: + ::chat::contacts::NodeEntry* findOrCreate(uint32_t node_id) + { + for (auto& entry : entries) + { + if (entry.node_id == node_id) + { + return &entry; + } + } + ::chat::contacts::NodeEntry entry{}; + entry.node_id = node_id; + entries.push_back(entry); + return &entries.back(); + } + + std::vector<::chat::contacts::NodeEntry> entries; +}; + +class FakeContactStore final : public ::chat::contacts::IContactStore +{ + public: + void begin() override {} + std::string getNickname(uint32_t) const override { return {}; } + bool setNickname(uint32_t, const char*) override { return false; } + bool removeNickname(uint32_t) override { return false; } + bool hasNickname(const char*) const override { return false; } + std::vector getAllContactIds() const override { return {}; } + size_t getCount() const override { return 0; } +}; + ui::chat::ConversationId directPeer(uint32_t peer) { ui::chat::ConversationId id; @@ -133,6 +268,33 @@ ui::chat::ConversationId systemConversation() return id; } +const ui::chat::ConversationLocationParticipant* findLocationParticipant( + const ui::chat::ChatWorkspaceSnapshot& snapshot, + uint32_t node_id) +{ + for (size_t i = 0; i < snapshot.location_participant_count; ++i) + { + if (snapshot.location_participants[i].node_id == node_id) + { + return &snapshot.location_participants[i]; + } + } + return nullptr; +} + +void setNodePosition(::chat::contacts::ContactService& contacts, + uint32_t node_id, + int32_t lat_e7, + int32_t lon_e7) +{ + ::chat::contacts::NodePosition pos{}; + pos.valid = true; + pos.latitude_i = lat_e7; + pos.longitude_i = lon_e7; + pos.timestamp = 1700000000U; + contacts.updateNodePosition(node_id, pos); +} + } // namespace int main() @@ -144,13 +306,19 @@ int main() ::chat::ChatModel model; FakeMeshAdapter mesh; + mesh.self_node_id = 0x01020304; ::chat::RamStore store; ::chat::ChatService service(model, mesh, store); ::chat::delivery::ChatDeliveryReadModel delivery_read_model; + FakeNodeStore node_store; + FakeContactStore contact_store; + ::chat::contacts::ContactService contacts(node_store, contact_store); + setNodePosition(contacts, mesh.self_node_id, 312345678, 1219876543); + setNodePosition(contacts, 1234, 313000000, 1220000000); ui::presentation_sources::RuntimeChatActionSink sink(service); ui::presentation_sources::ChatPresentationSource source( - service, nullptr, &delivery_read_model); + service, &contacts, &delivery_read_model, &mesh); const ui::chat::ConversationId ada = directPeer(1234); ui::chat::SendMessageView send; @@ -207,6 +375,18 @@ int main() assert(std::strcmp(snapshot.messages[0].text.c_str(), "hello") == 0); assert(snapshot.can_send); assert(snapshot.composer_enabled); + assert(snapshot.location_participant_count == 2); + const auto* self_location = + findLocationParticipant(snapshot, mesh.self_node_id); + assert(self_location != nullptr); + assert(self_location->self); + assert(self_location->valid); + assert(self_location->lat > 31.23 && self_location->lat < 31.24); + const auto* peer_location = findLocationParticipant(snapshot, 1234); + assert(peer_location != nullptr); + assert(!peer_location->self); + assert(peer_location->valid); + assert(peer_location->lon > 121.99 && peer_location->lon < 122.01); mesh.send_ok = false; mesh.send_failure = ::chat::MeshOperationFailure::PeerKeyMissing; 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 59baafb4..1fb4c268 100644 --- a/modules/ui_shared/tests/test_team_chat_presentation_source.cpp +++ b/modules/ui_shared/tests/test_team_chat_presentation_source.cpp @@ -1,5 +1,6 @@ #include "platform/ui/team_ui_chat_log_store.h" #include "platform/ui/team_ui_snapshot_store.h" +#include "platform/ui/team_ui_store_runtime.h" #include "team/protocol/team_chat.h" #include "team/protocol/team_location_marker.h" #include "ui/presentation_sources/team_chat_presentation_source.h" @@ -24,6 +25,7 @@ team::TeamId testTeamId() team::ui::TeamUiSnapshot makeSnapshot() { team::ui::TeamUiSnapshot snap; + snap.in_team = true; snap.has_team_id = true; snap.team_id = testTeamId(); snap.team_name = "Alpha"; @@ -55,6 +57,20 @@ bool contains(const ui::FixedText<160>& text, const char* needle) return std::strstr(text.c_str(), needle) != nullptr; } +const ui::chat::ConversationLocationParticipant* findLocationParticipant( + const ui::chat::ChatWorkspaceSnapshot& snapshot, + uint32_t node_id) +{ + for (size_t i = 0; i < snapshot.location_participant_count; ++i) + { + if (snapshot.location_participants[i].node_id == node_id) + { + return &snapshot.location_participants[i]; + } + } + return nullptr; +} + } // namespace int main() @@ -64,6 +80,14 @@ int main() const team::ui::TeamUiSnapshot snap = makeSnapshot(); store.save(snap); + assert(team::ui::team_ui_posring_append( + snap.team_id, + 0x12345678, + 312345678, + 1219876543, + 42, + 0, + 100)); assert(team::ui::team_ui_chat_log_store().appendText( snap.team_id, 0x12345678, true, 100, "hello team")); @@ -117,6 +141,13 @@ int main() assert(selected.message_count == 3); assert(selected.can_send); assert(selected.composer_enabled); + assert(selected.location_participant_count == 1); + const auto* member_location = + findLocationParticipant(selected, 0x12345678); + assert(member_location != nullptr); + assert(member_location->valid); + assert(member_location->lat > 31.23 && member_location->lat < 31.24); + assert(member_location->lon > 121.98 && member_location->lon < 121.99); assert(selected.messages[0].conversation.kind == ui::chat::ConversationKind::Team);