From 731d4e04ffc7be9ebb140c113110d0dcfa3dca80 Mon Sep 17 00:00:00 2001 From: DanS Date: Wed, 15 Jul 2026 10:08:04 -0500 Subject: [PATCH] feat(contacts): avatars in Table view + prune orphaned avatar images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Table view now shows the same avatar (image / icon / Z-T badge) before each contact's label, drawn after the row Selectable so its highlight doesn't paint over it — visual parity with Cards/List. - When a contact's custom image avatar is replaced (edit) or the contact is deleted, its now-unused file in /contact-avatars/ is removed (pruneOrphanAvatar) — but only if no other contact still references it and the path is inside our managed dir, and its cached texture is dropped too. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ui/windows/contacts_tab.cpp | 36 ++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/ui/windows/contacts_tab.cpp b/src/ui/windows/contacts_tab.cpp index ec4bdbf..790bfec 100644 --- a/src/ui/windows/contacts_tab.cpp +++ b/src/ui/windows/contacts_tab.cpp @@ -157,6 +157,24 @@ static std::string copyAvatarImage(const std::string& srcPath) { return ec ? std::string() : dst.string(); } +// Delete a no-longer-referenced avatar image file. Call AFTER the address book has been updated with +// `oldAvatar`'s reference removed: if `oldAvatar` is an "img:" avatar that no contact still uses AND it +// lives inside our managed /contact-avatars/ dir (never touch anything outside it), remove the +// file and drop its cached texture. A no-op for badge/icon avatars or a still-shared image. +static void pruneOrphanAvatar(const std::string& oldAvatar, const data::AddressBook& book) { + if (oldAvatar.rfind("img:", 0) != 0) return; + for (const auto& e : book.entries()) if (e.avatar == oldAvatar) return; // still referenced elsewhere + namespace fs = std::filesystem; + std::error_code ec; + const std::string path = oldAvatar.substr(4); + fs::path managed = fs::weakly_canonical(fs::path(util::Platform::getConfigDir()) / "contact-avatars", ec); + fs::path target = fs::weakly_canonical(fs::path(path), ec); + // Only prune files actually inside the managed dir (guards against a hand-edited external path). + if (managed.empty() || target.string().rfind(managed.string(), 0) != 0) return; + fs::remove(target, ec); + invalidateAvatarTexture(path); +} + void RenderContactsTab(App* app) { auto& S = schema::UI(); @@ -202,7 +220,9 @@ void RenderContactsTab(App* app) auto doDelete = [&]() { if (!selValid()) return; if (s_confirm_delete_idx == s_selected_index) { + std::string removedAvatar = book.entries()[s_selected_index].avatar; book.removeEntry(s_selected_index); + pruneOrphanAvatar(removedAvatar, book); // drop its image if no other contact uses it s_selected_index = -1; s_confirm_delete_idx = -1; Notifications::instance().success(TR("address_book_deleted")); @@ -630,7 +650,11 @@ void RenderContactsTab(App* app) // (pre-connect) — better a visible-everywhere contact than one orphaned to no wallet. entry.scope = (s_edit_global || activeHash.empty()) ? std::string("global") : activeHash; if (isEdit) { + // Capture the previous avatar so we can prune its now-unused image after the update. + std::string prevAvatar = (s_selected_index >= 0 && s_selected_index < (int)app->addressBook().size()) + ? app->addressBook().entries()[s_selected_index].avatar : std::string(); if (app->addressBook().updateEntry(s_selected_index, entry)) { + if (prevAvatar != entry.avatar) pruneOrphanAvatar(prevAvatar, app->addressBook()); Notifications::instance().success(TR("address_book_updated")); s_show_edit_dialog = false; } else { @@ -824,8 +848,16 @@ void RenderContactsTab(App* app) const auto& entry = book.entries()[i]; ImGui::TableNextRow(); ImGui::PushID(static_cast(i)); + const bool shielded = isShieldedAddr(entry.address); ImGui::TableNextColumn(); bool is_selected = (s_selected_index == static_cast(i)); + // Small avatar before the label. SpanAllColumns keeps the whole row clickable; the avatar + // is drawn AFTER the Selectable so its selection/hover fill doesn't paint over it. + const float tLineH = ImGui::GetTextLineHeight(); + const float tAvR = tLineH * 0.5f; + const ImVec2 tAvP = ImGui::GetCursorScreenPos(); + ImGui::Dummy(ImVec2(tAvR * 2.0f, tLineH)); + ImGui::SameLine(0.0f, 6.0f * dp); if (ImGui::Selectable(entry.label.c_str(), is_selected, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick)) { if (s_selected_index != static_cast(i)) s_confirm_delete_idx = -1; @@ -835,8 +867,10 @@ void RenderContactsTab(App* app) if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { s_selected_index = static_cast(i); s_confirm_delete_idx = -1; openContextMenu = true; } + drawContactAvatar(ImGui::GetWindowDrawList(), ImVec2(tAvP.x + tAvR, tAvP.y + tLineH * 0.5f), + tAvR, entry, shielded, typeColor(shielded), lightTheme, dp, + material::Type().caption(), material::Type().iconSmall()); ImGui::TableNextColumn(); - bool shielded = isShieldedAddr(entry.address); ImGui::PushFont(material::Type().subtitle2()); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(typeColor(shielded)), "%s", shielded ? "Z" : "T"); ImGui::PopFont();