From 794150b11baf70bf518398947f01e9997cbe421b Mon Sep 17 00:00:00 2001 From: DanS Date: Sun, 18 Oct 2026 23:51:07 -0500 Subject: [PATCH] feat(chat,notifications): first-run chat intro card; clickable toast history + "+N" badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Chat: a dismissible first-run intro card (how chat works — encrypted, sent as shielded txs with a small fee, tied to the seed) with a "Copy my chat address" button, shown in the empty thread pane; dismissal persisted. The copy logic is shared with the existing sidebar button (moved to a helper, unchanged). - Toasts: clicking a toast now opens the existing notification-history panel, and a "+N more" badge appears when several live toasts are queued (render only ever showed the newest). Badge opacity is a ui.toml token. Co-Authored-By: Claude Opus 4.8 --- res/themes/ui.toml | 1 + src/app.cpp | 2 +- src/config/settings.cpp | 2 ++ src/config/settings.h | 3 +++ src/ui/notifications.cpp | 28 ++++++++++++++++++-- src/ui/notifications.h | 13 ++++++++++ src/ui/windows/chat_tab.cpp | 51 +++++++++++++++++++++++++++++-------- 7 files changed, 86 insertions(+), 14 deletions(-) diff --git a/res/themes/ui.toml b/res/themes/ui.toml index 3d3d8fe..60ebe3f 100644 --- a/res/themes/ui.toml +++ b/res/themes/ui.toml @@ -1226,6 +1226,7 @@ pill-rounding = { size = 12.0 } padding-x = { size = 12.0 } glass-fill-alpha = { size = 18.0 } accent-fill-alpha = { size = 0.22 } # severity-tint fill opacity (fraction of the accent alpha) +badge-fill-alpha = { size = 0.35 } # "+N more" badge fill opacity (fraction of the accent alpha) glass-border-alpha = { size = 35.0 } progress-bar-height = { size = 2.0 } duration-info = { size = 1.0 } diff --git a/src/app.cpp b/src/app.cpp index 104fae5..448ccea 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -3317,7 +3317,7 @@ void App::renderStatusBar() ImVec2(bellMax.x - r, bellMin.y + r), r, dotCol); } - if (clicked) { + if (clicked || notes.consumeHistoryRequest()) { // bell click, or a click on the toast itself alerts_seen_total_ = notes.totalPushed(); // mark everything currently shown as seen ImGui::OpenPopup("##AlertHistoryPopup"); } diff --git a/src/config/settings.cpp b/src/config/settings.cpp index ae589ae..ebabe7b 100644 --- a/src/config/settings.cpp +++ b/src/config/settings.cpp @@ -271,6 +271,7 @@ bool Settings::load(const std::string& path) } loadScalar(j, "wizard_completed", wizard_completed_); loadScalar(j, "seed_backup_reminded", seed_backup_reminded_); + loadScalar(j, "chat_intro_dismissed", chat_intro_dismissed_); loadScalar(j, "lock_failed_attempts", lock_failed_attempts_); loadScalar(j, "lockout_until_epoch", lockout_until_epoch_); loadScalar(j, "lock_state_wallet", lock_state_wallet_); @@ -599,6 +600,7 @@ bool Settings::save(const std::string& path) } j["wizard_completed"] = wizard_completed_; j["seed_backup_reminded"] = seed_backup_reminded_; + j["chat_intro_dismissed"] = chat_intro_dismissed_; j["lock_failed_attempts"] = lock_failed_attempts_; j["lockout_until_epoch"] = lockout_until_epoch_; j["lock_state_wallet"] = lock_state_wallet_; diff --git a/src/config/settings.h b/src/config/settings.h index a12bb86..d0e4829 100644 --- a/src/config/settings.h +++ b/src/config/settings.h @@ -392,6 +392,8 @@ public: // Whether the one-time "back up your seed phrase" reminder has already been shown. bool getSeedBackupReminded() const { return seed_backup_reminded_; } void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; } + bool getChatIntroDismissed() const { return chat_intro_dismissed_; } + void setChatIntroDismissed(bool v) { chat_intro_dismissed_ = v; durable_next_save_ = true; } // Unlock lockout state, persisted across restarts so the failed-attempt curve can't be reset by // killing the app. Keyed by the wallet file it applies to; the PIN/passphrase is never stored. @@ -745,6 +747,7 @@ private: std::map address_meta_; bool wizard_completed_ = false; bool seed_backup_reminded_ = false; + bool chat_intro_dismissed_ = false; // one-time chat "how it works" intro card int lock_failed_attempts_ = 0; // persisted unlock-failure count (lockout curve) long long lockout_until_epoch_ = 0; // unix time the current lockout expires (0 = none) std::string lock_state_wallet_; // wallet file the above two apply to diff --git a/src/ui/notifications.cpp b/src/ui/notifications.cpp index 3bc238a..3122d2a 100644 --- a/src/ui/notifications.cpp +++ b/src/ui/notifications.cpp @@ -100,6 +100,15 @@ void Notifications::render() float iconGap = 4.0f * dp; float msgW = textFont ? textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, notif.message.c_str()).x : 100.0f; float pillWidth = pillPadX + iconW + iconGap + msgW + pillPadX; + // "+N more" badge when several live toasts are queued (render shows only the newest). + const size_t moreCount = hiddenLiveCount(); + std::string badge = moreCount ? ("+" + std::to_string(moreCount)) : std::string(); + float badgeW = 0.0f, badgeGap = 0.0f; + if (!badge.empty() && textFont) { + badgeW = textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, badge.c_str()).x + 10.0f * dp; + badgeGap = 6.0f * dp; + pillWidth += badgeW + badgeGap; + } // Clamp to reasonable bounds float maxPillW = viewport->WorkSize.x * 0.5f; if (pillWidth > maxPillW) pillWidth = maxPillW; @@ -163,7 +172,7 @@ void Notifications::render() bool clipped = false; if (textFont) { ImU32 textCol = ImGui::ColorConvertFloat4ToU32(text_color); - const float availTextW = (pMax.x - pillPadX) - cursorX; + const float availTextW = (pMax.x - pillPadX) - cursorX - (badge.empty() ? 0.0f : (badgeW + badgeGap)); std::string shown = notif.message; if (availTextW > 0.0f && textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, shown.c_str()).x > availTextW) { @@ -183,8 +192,23 @@ void Notifications::render() dl->PopClipRect(); } - // Hover: pause the auto-dismiss (handled next frame) and reveal the full message if it was clipped. + // "+N more" badge, right-aligned inside the pill. + if (!badge.empty() && textFont) { + float badgeX = pMax.x - pillPadX - badgeW; + ImVec4 bf = accent_color; bf.w *= nde("badge-fill-alpha", 0.35f); + dl->AddRectFilled(ImVec2(badgeX, pillY + 3.0f * dp), ImVec2(badgeX + badgeW, pMax.y - 3.0f * dp), + ImGui::ColorConvertFloat4ToU32(bf), (pillHeight - 6.0f * dp) * 0.5f); + dl->AddText(textFont, textFont->LegacySize, ImVec2(badgeX + 5.0f * dp, contentY), + ImGui::ColorConvertFloat4ToU32(text_color), badge.c_str()); + } + + // Hover: pause the auto-dismiss (next frame), reveal the full message if clipped, and allow a click to + // open the notification history (the status bar consumes the request). toast_hovered_ = ImGui::IsMouseHoveringRect(pMin, pMax); + if (toast_hovered_) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) requestHistory(); + } if (toast_hovered_ && clipped) ImGui::SetTooltip("%s", notif.message.c_str()); } diff --git a/src/ui/notifications.h b/src/ui/notifications.h index 298fc85..011f7b5 100644 --- a/src/ui/notifications.h +++ b/src/ui/notifications.h @@ -169,8 +169,20 @@ public: void clear() { notifications_.clear(); + history_requested_ = false; } + /// Number of currently-live toasts beyond the one the pill shows (for a "+N more" badge). + size_t hiddenLiveCount() const { + const auto now = std::chrono::steady_clock::now(); + size_t live = 0; + for (const auto& n : notifications_) if (!n.isExpiredAt(now)) ++live; + return live > 1 ? live - 1 : 0; + } + /// render() sets this when the toast is clicked; the status bar consumes it to open the history panel. + void requestHistory() { history_requested_ = true; } + bool consumeHistoryRequest() { bool r = history_requested_; history_requested_ = false; return r; } + void setMaxNotifications(size_t max) { max_notifications_ = max; } @@ -191,6 +203,7 @@ private: Notifications& operator=(const Notifications&) = delete; std::deque notifications_; + bool history_requested_ = false; // set on toast click, consumed by the status bar to open history size_t max_notifications_ = 5; bool toast_hovered_ = false; // pointer was over the toast last frame → pause its auto-dismiss std::chrono::steady_clock::time_point last_drawn_{}; diff --git a/src/ui/windows/chat_tab.cpp b/src/ui/windows/chat_tab.cpp index 80be222..cabc555 100644 --- a/src/ui/windows/chat_tab.cpp +++ b/src/ui/windows/chat_tab.cpp @@ -591,6 +591,17 @@ void centeredHint(const char* text) { } // Centered empty state: big muted icon + title + optional wrapped hint (V4). +// Copy the user's own chat address to the clipboard (shared by the sidebar button and the intro card). +void copyMyChatAddress(App* app) { + std::string myAddr = app->chatMyAddress(); + if (myAddr.empty()) { + Notifications::instance().warning(TR("chat_no_my_address")); + } else { + ImGui::SetClipboardText(myAddr.c_str()); + Notifications::instance().info(TR("address_copied")); + } +} + void centeredEmptyState(const char* icon, const char* title, const char* hint) { const ImVec2 avail = ImGui::GetContentRegionAvail(); const ImVec2 origin = ImGui::GetCursorPos(); @@ -956,15 +967,7 @@ void RenderChatTab(App* app) { ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium()); // Text-only: SmallButton uses the body font, which has no Material icon glyph (would show "?"). - if (ImGui::SmallButton(TR("chat_copy_my_address"))) { - std::string myAddr = app->chatMyAddress(); - if (myAddr.empty()) { - Notifications::instance().warning(TR("chat_no_my_address")); - } else { - ImGui::SetClipboardText(myAddr.c_str()); - Notifications::instance().info(TR("address_copied")); - } - } + if (ImGui::SmallButton(TR("chat_copy_my_address"))) copyMyChatAddress(app); ImGui::PopStyleColor(); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("chat_my_address_tip")); } @@ -1875,9 +1878,35 @@ void RenderChatTab(App* app) ImGui::EndChild(); ImGui::PopStyleVar(2); // ##ChatMessages WindowPadding + zeroed vertical ItemSpacing // Composer is rendered OUTSIDE this box, directly below it (see after EndChild). + } else if (convs.empty() && app->settings() && !app->settings()->getChatIntroDismissed()) { + // First-run intro: explain how chat works + let the user copy their address, instead of a bare + // "no conversations yet" line. Dismissal is persisted. + const float idp = Layout::dpiScale(); + const float availX = ImGui::GetContentRegionAvail().x; + const float cardW = std::min(availX - 40.0f * idp, 460.0f * idp); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, (availX - cardW) * 0.5f)); + ImGui::BeginGroup(); + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + cardW); + material::Type().textColored(material::TypeStyle::Subtitle1, material::OnSurface(), TR("chat_intro_title")); + ImGui::Spacing(); + ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium()); + ImGui::TextWrapped("%s", TR("chat_intro_body")); + ImGui::Spacing(); + ImGui::TextWrapped("%s", TR("chat_intro_fee")); + ImGui::PopStyleColor(); + ImGui::PopTextWrapPos(); + ImGui::Dummy(ImVec2(0, Layout::spacingMd())); + if (material::TactileButton(TR("chat_copy_my_address"), ImVec2(cardW, 0))) copyMyChatAddress(app); + ImGui::Spacing(); + if (material::TactileButton(TR("chat_intro_dismiss"), ImVec2(cardW, 0))) { + app->settings()->setChatIntroDismissed(true); + app->settings()->save(); + } + ImGui::EndGroup(); + } else if (convs.empty()) { + centeredEmptyState(ICON_MD_FORUM, TR("chat_empty_title"), TR("chat_empty_start")); } else { - if (convs.empty()) centeredEmptyState(ICON_MD_FORUM, TR("chat_empty_title"), TR("chat_empty_start")); - else centeredEmptyState(ICON_MD_CHAT_BUBBLE_OUTLINE, TR("chat_select_hint"), nullptr); + centeredEmptyState(ICON_MD_CHAT_BUBBLE_OUTLINE, TR("chat_select_hint"), nullptr); } } ImGui::EndChild();