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:
@@ -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
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user