feat(chat): emoji picker + show-hidden toggle

- Emoji picker: a 🙂 button on the composer row opens a scrollable grid of ~150
  common single-codepoint emoji (all verified present in the bundled NotoEmoji
  subset); clicking appends to the composer, respecting the byte cap. ImGui does
  no shaping, so the set is single-codepoint only (ZWJ sequences / flags omitted).
- Show hidden: when there are hidden conversations, a "Show hidden (N)" toggle
  appears in the list; toggling it reveals them (dimmed) and the thread header's
  Hide button becomes Unhide. Off by default, and resets when nothing is hidden.

8-language strings; no new CJK glyphs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 01:08:35 -05:00
parent 125ffa8863
commit 03fe6e077c
10 changed files with 105 additions and 11 deletions

View File

@@ -48,6 +48,7 @@ bool s_show_new_convo = false;
char s_new_zaddr[128] = "";
char s_new_msg[256] = "";
char s_search[80] = ""; // conversation-list filter (Q8)
bool s_show_hidden = false; // when on, the list also shows hidden conversations (with an Unhide action)
// Effective draw-list font size for a material font (mirrors sidebar's ScaledFontSize).
float scaledSize(ImFont* f) { return f->LegacySize * ImGui::GetStyle().FontScaleMain; }
@@ -136,6 +137,7 @@ struct ConvSummary {
std::string lastBody;
std::int64_t lastTs = 0;
int count = 0;
bool hidden = false; // shown only while "Show hidden" is on
};
// Centered, muted, wrapped hint for the empty states.
@@ -199,6 +201,42 @@ void centeredEmptyState(const char* icon, const char* title, const char* hint) {
}
}
// Emoji picker popup: a scrollable grid of common single-codepoint emoji (verified present in the
// bundled NotoEmoji subset). Clicking one appends its UTF-8 bytes to `buf` (respecting the buffer).
// The set is single-codepoint only — ImGui does no shaping, so ZWJ sequences / flags wouldn't compose.
void emojiPickerPopup(const char* popupId, char* buf, std::size_t bufSize) {
static const char* const kEmoji =
u8"😀😃😄😁😆😅😂🤣😊😇🙂🙃😉😌😍🥰😘😗😙😚😋😛😝😜🤪🤨🧐🤓😎🥳😏😒😞😔😟😕🙁😣😖😫😩🥺😢😭😤"
u8"😠😡🤬🤯😳🥵🥶😱😨😰😥😓🤗🤔🤭🤫🤥😶😐😑😬🙄😯😦😧😮😲🥱😴🤤😪🤐🥴🤢🤮🤧😷🤒🤕😈👿💀👻👽🤖😺🙀"
u8"👍👎👌✌🤞🤟🤘👏🙌👐🙏💪👋🤙👊✊🤛🤜👆👇👈👉"
u8"❤🧡💛💚💙💜🖤🤍💔💕💞💗💖💘💝🔥⭐🎉🎊✨💯✅❌❓❗👀🎂🚀💰🎁🍺🍻☕🍕👑💎🌟💥";
const float dp = Layout::dpiScale();
ImGui::SetNextWindowSizeConstraints(ImVec2(0, 0), ImVec2(FLT_MAX, 260.0f * dp)); // cap height → scrolls
if (!ImGui::BeginPopup(popupId)) return;
ImGui::PushFont(material::Type().subtitle1()); // larger + emoji-capable
const float cell = 30.0f * dp;
const int perRow = 9;
int idx = 0;
for (const char* s = kEmoji; *s;) {
const unsigned char lead = static_cast<unsigned char>(*s);
const int len = (lead >= 0xF0) ? 4 : (lead >= 0xE0) ? 3 : (lead >= 0xC0) ? 2 : 1;
char glyph[5] = {0};
for (int k = 0; k < len && s[k]; ++k) glyph[k] = s[k];
ImGui::PushID(idx);
if (ImGui::Button(glyph, ImVec2(cell, cell))) {
const std::size_t cur = std::strlen(buf);
const std::size_t add = std::strlen(glyph);
if (cur + add < bufSize) { std::memcpy(buf + cur, glyph, add); buf[cur + add] = '\0'; }
}
ImGui::PopID();
if ((idx + 1) % perRow != 0) ImGui::SameLine();
s += len;
++idx;
}
ImGui::PopFont();
ImGui::EndPopup();
}
} // namespace
void RenderChatTab(App* app)
@@ -215,12 +253,16 @@ void RenderChatTab(App* app)
// Build conversation summaries (single scan per conversation), sorted by most-recent activity.
std::vector<ConvSummary> convs;
int hiddenCount = 0;
for (const auto& cid : store.conversationIds()) {
if (app->settings() && app->settings()->isChatHidden(cid)) continue; // hidden — a new message un-hides it
const bool hidden = app->settings() && app->settings()->isChatHidden(cid);
if (hidden) ++hiddenCount;
if (hidden && !s_show_hidden) continue; // filtered out unless "Show hidden" is on
const auto messages = store.conversation(cid);
if (messages.empty()) continue;
ConvSummary c;
c.cid = cid;
c.hidden = hidden;
c.count = static_cast<int>(messages.size());
for (const auto& m : messages) { // pin to the EARLIEST (establishing) peer z-addr / key (B2 — the
if (c.peerZaddr.empty() && !m.peer_zaddr.empty()) c.peerZaddr = m.peer_zaddr; // memo header rides
@@ -285,6 +327,18 @@ void RenderChatTab(App* app)
ImGui::SetNextItemWidth(-FLT_MIN);
ImGui::InputTextWithHint("##chatsearch", TR("chat_search"), s_search, sizeof(s_search));
}
// Show-hidden toggle — only when there are hidden conversations. Reveals them in the list (with
// an Unhide action in the thread header).
if (hiddenCount > 0) {
ImGui::PushFont(metaFont);
const std::string lbl = s_show_hidden
? std::string(TR("chat_hide_hidden"))
: std::string(TR("chat_show_hidden")) + " (" + std::to_string(hiddenCount) + ")";
if (ImGui::SmallButton(lbl.c_str())) s_show_hidden = !s_show_hidden;
ImGui::PopFont();
} else {
s_show_hidden = false; // nothing hidden → keep the toggle off
}
const std::string search = s_search;
ImGui::Separator();
if (convs.empty()) {
@@ -335,8 +389,9 @@ void RenderChatTab(App* app)
}
const float textX = avC.x + avR + pad;
// Name (top).
dl->AddText(nameFont, nameSz, ImVec2(textX, p.y + pad), material::OnSurface(), c.peerName.c_str());
// Name (top). Hidden conversations (shown via "Show hidden") render dimmed.
dl->AddText(nameFont, nameSz, ImVec2(textX, p.y + pad),
c.hidden ? material::OnSurfaceMedium() : material::OnSurface(), c.peerName.c_str());
// Time (top-right, muted) — compact relative form (Q5).
const std::string when = relativeTime(c.lastTs);
if (!when.empty()) {
@@ -440,15 +495,20 @@ void RenderChatTab(App* app)
app->settings()->setChatMuted(sel->cid, !muted);
app->settings()->save();
}
// Hide conversation — drops it from the list (messages stay in the encrypted store; a
// new incoming message brings it back). Deselect so the thread resets to the next one.
// Hide / Unhide. Hiding drops it from the list (messages stay in the encrypted store);
// Unhide (shown only for a hidden conversation while "Show hidden" is on) restores it.
ImGui::SameLine();
if (ImGui::SmallButton(TR("chat_hide")) && app->settings()) {
app->markChatConversationSeen(sel->cid, sel->lastTs); // don't leave a phantom unread
app->settings()->setChatHidden(sel->cid, true);
app->settings()->save();
s_selected_cid.clear();
Notifications::instance().info(TR("chat_hidden_toast"));
if (ImGui::SmallButton(sel->hidden ? TR("chat_unhide") : TR("chat_hide")) && app->settings()) {
if (sel->hidden) {
app->settings()->setChatHidden(sel->cid, false);
app->settings()->save();
} else {
app->markChatConversationSeen(sel->cid, sel->lastTs); // don't leave a phantom unread
app->settings()->setChatHidden(sel->cid, true);
app->settings()->save();
s_selected_cid.clear();
Notifications::instance().info(TR("chat_hidden_toast"));
}
}
}
ImGui::Separator();
@@ -596,6 +656,13 @@ void RenderChatTab(App* app)
if (material::TactileButton(TR("chat_send"), ImVec2(sendW, composerBoxH))) submit = true;
ImGui::EndDisabled();
ImGui::PopStyleColor(3);
// Emoji picker button (left) + byte counter (right) on one row.
ImGui::PushFont(nameFont); // Body2 has emoji merged, so the glyph renders
const bool emojiClicked = ImGui::SmallButton(u8"🙂");
ImGui::PopFont();
if (emojiClicked) ImGui::OpenPopup("##emojipick");
emojiPickerPopup("##emojipick", s_compose, sizeof(s_compose));
ImGui::SameLine();
// Byte counter (Error tint over cap) with the over-cap label.
ImGui::PushFont(metaFont);
ImGui::PushStyleColor(ImGuiCol_Text, overCap ? material::Error() : material::OnSurfaceMedium());

View File

@@ -253,6 +253,9 @@ void I18n::loadBuiltinEnglish()
strings_["chat_mute"] = "Mute";
strings_["chat_unmute"] = "Unmute";
strings_["chat_hide"] = "Hide";
strings_["chat_unhide"] = "Unhide";
strings_["chat_show_hidden"] = "Show hidden";
strings_["chat_hide_hidden"] = "Hide hidden";
strings_["chat_hidden_toast"] = "Conversation hidden — a new message brings it back";
strings_["chat_pick_contact"] = "Choose from contacts\xE2\x80\xA6";
strings_["chat_no_z_contacts"] = "No shielded-address contacts yet";