feat(chat,notifications): first-run chat intro card; clickable toast history + "+N" badge

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 23:51:07 -05:00
co-authored by Claude Opus 4.8
parent 2e74a3ca69
commit 794150b11b
7 changed files with 86 additions and 14 deletions
+1
View File
@@ -1226,6 +1226,7 @@ pill-rounding = { size = 12.0 }
padding-x = { size = 12.0 } padding-x = { size = 12.0 }
glass-fill-alpha = { size = 18.0 } glass-fill-alpha = { size = 18.0 }
accent-fill-alpha = { size = 0.22 } # severity-tint fill opacity (fraction of the accent alpha) 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 } glass-border-alpha = { size = 35.0 }
progress-bar-height = { size = 2.0 } progress-bar-height = { size = 2.0 }
duration-info = { size = 1.0 } duration-info = { size = 1.0 }
+1 -1
View File
@@ -3317,7 +3317,7 @@ void App::renderStatusBar()
ImVec2(bellMax.x - r, bellMin.y + r), r, dotCol); 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 alerts_seen_total_ = notes.totalPushed(); // mark everything currently shown as seen
ImGui::OpenPopup("##AlertHistoryPopup"); ImGui::OpenPopup("##AlertHistoryPopup");
} }
+2
View File
@@ -271,6 +271,7 @@ bool Settings::load(const std::string& path)
} }
loadScalar(j, "wizard_completed", wizard_completed_); loadScalar(j, "wizard_completed", wizard_completed_);
loadScalar(j, "seed_backup_reminded", seed_backup_reminded_); 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, "lock_failed_attempts", lock_failed_attempts_);
loadScalar(j, "lockout_until_epoch", lockout_until_epoch_); loadScalar(j, "lockout_until_epoch", lockout_until_epoch_);
loadScalar(j, "lock_state_wallet", lock_state_wallet_); 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["wizard_completed"] = wizard_completed_;
j["seed_backup_reminded"] = seed_backup_reminded_; j["seed_backup_reminded"] = seed_backup_reminded_;
j["chat_intro_dismissed"] = chat_intro_dismissed_;
j["lock_failed_attempts"] = lock_failed_attempts_; j["lock_failed_attempts"] = lock_failed_attempts_;
j["lockout_until_epoch"] = lockout_until_epoch_; j["lockout_until_epoch"] = lockout_until_epoch_;
j["lock_state_wallet"] = lock_state_wallet_; j["lock_state_wallet"] = lock_state_wallet_;
+3
View File
@@ -392,6 +392,8 @@ public:
// Whether the one-time "back up your seed phrase" reminder has already been shown. // Whether the one-time "back up your seed phrase" reminder has already been shown.
bool getSeedBackupReminded() const { return seed_backup_reminded_; } bool getSeedBackupReminded() const { return seed_backup_reminded_; }
void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; } 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 // 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. // killing the app. Keyed by the wallet file it applies to; the PIN/passphrase is never stored.
@@ -745,6 +747,7 @@ private:
std::map<std::string, AddressMeta> address_meta_; std::map<std::string, AddressMeta> address_meta_;
bool wizard_completed_ = false; bool wizard_completed_ = false;
bool seed_backup_reminded_ = 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) 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) 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 std::string lock_state_wallet_; // wallet file the above two apply to
+26 -2
View File
@@ -100,6 +100,15 @@ void Notifications::render()
float iconGap = 4.0f * dp; float iconGap = 4.0f * dp;
float msgW = textFont ? textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, notif.message.c_str()).x : 100.0f; 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; 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 // Clamp to reasonable bounds
float maxPillW = viewport->WorkSize.x * 0.5f; float maxPillW = viewport->WorkSize.x * 0.5f;
if (pillWidth > maxPillW) pillWidth = maxPillW; if (pillWidth > maxPillW) pillWidth = maxPillW;
@@ -163,7 +172,7 @@ void Notifications::render()
bool clipped = false; bool clipped = false;
if (textFont) { if (textFont) {
ImU32 textCol = ImGui::ColorConvertFloat4ToU32(text_color); 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; std::string shown = notif.message;
if (availTextW > 0.0f && if (availTextW > 0.0f &&
textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, shown.c_str()).x > availTextW) { textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, shown.c_str()).x > availTextW) {
@@ -183,8 +192,23 @@ void Notifications::render()
dl->PopClipRect(); 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); toast_hovered_ = ImGui::IsMouseHoveringRect(pMin, pMax);
if (toast_hovered_) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) requestHistory();
}
if (toast_hovered_ && clipped) if (toast_hovered_ && clipped)
ImGui::SetTooltip("%s", notif.message.c_str()); ImGui::SetTooltip("%s", notif.message.c_str());
} }
+13
View File
@@ -169,8 +169,20 @@ public:
void clear() { void clear() {
notifications_.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) { void setMaxNotifications(size_t max) {
max_notifications_ = max; max_notifications_ = max;
} }
@@ -191,6 +203,7 @@ private:
Notifications& operator=(const Notifications&) = delete; Notifications& operator=(const Notifications&) = delete;
std::deque<Notification> notifications_; std::deque<Notification> notifications_;
bool history_requested_ = false; // set on toast click, consumed by the status bar to open history
size_t max_notifications_ = 5; size_t max_notifications_ = 5;
bool toast_hovered_ = false; // pointer was over the toast last frame → pause its auto-dismiss bool toast_hovered_ = false; // pointer was over the toast last frame → pause its auto-dismiss
std::chrono::steady_clock::time_point last_drawn_{}; std::chrono::steady_clock::time_point last_drawn_{};
+40 -11
View File
@@ -591,6 +591,17 @@ void centeredHint(const char* text) {
} }
// Centered empty state: big muted icon + title + optional wrapped hint (V4). // 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) { void centeredEmptyState(const char* icon, const char* title, const char* hint) {
const ImVec2 avail = ImGui::GetContentRegionAvail(); const ImVec2 avail = ImGui::GetContentRegionAvail();
const ImVec2 origin = ImGui::GetCursorPos(); const ImVec2 origin = ImGui::GetCursorPos();
@@ -956,15 +967,7 @@ void RenderChatTab(App* app)
{ {
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium()); ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
// Text-only: SmallButton uses the body font, which has no Material icon glyph (would show "?"). // Text-only: SmallButton uses the body font, which has no Material icon glyph (would show "?").
if (ImGui::SmallButton(TR("chat_copy_my_address"))) { if (ImGui::SmallButton(TR("chat_copy_my_address"))) copyMyChatAddress(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"));
}
}
ImGui::PopStyleColor(); ImGui::PopStyleColor();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("chat_my_address_tip")); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("chat_my_address_tip"));
} }
@@ -1875,9 +1878,35 @@ void RenderChatTab(App* app)
ImGui::EndChild(); ImGui::EndChild();
ImGui::PopStyleVar(2); // ##ChatMessages WindowPadding + zeroed vertical ItemSpacing ImGui::PopStyleVar(2); // ##ChatMessages WindowPadding + zeroed vertical ItemSpacing
// Composer is rendered OUTSIDE this box, directly below it (see after EndChild). // 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 { } else {
if (convs.empty()) centeredEmptyState(ICON_MD_FORUM, TR("chat_empty_title"), TR("chat_empty_start")); centeredEmptyState(ICON_MD_CHAT_BUBBLE_OUTLINE, TR("chat_select_hint"), nullptr);
else centeredEmptyState(ICON_MD_CHAT_BUBBLE_OUTLINE, TR("chat_select_hint"), nullptr);
} }
} }
ImGui::EndChild(); ImGui::EndChild();