feat(contacts): avatars in Table view + prune orphaned avatar images

- 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 <config>/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) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 10:08:04 -05:00
parent 9649654c3c
commit 731d4e04ff

View File

@@ -157,6 +157,24 @@ static std::string copyAvatarImage(const std::string& srcPath) {
return ec ? std::string() : dst.string(); 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 <config>/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) void RenderContactsTab(App* app)
{ {
auto& S = schema::UI(); auto& S = schema::UI();
@@ -202,7 +220,9 @@ void RenderContactsTab(App* app)
auto doDelete = [&]() { auto doDelete = [&]() {
if (!selValid()) return; if (!selValid()) return;
if (s_confirm_delete_idx == s_selected_index) { if (s_confirm_delete_idx == s_selected_index) {
std::string removedAvatar = book.entries()[s_selected_index].avatar;
book.removeEntry(s_selected_index); book.removeEntry(s_selected_index);
pruneOrphanAvatar(removedAvatar, book); // drop its image if no other contact uses it
s_selected_index = -1; s_selected_index = -1;
s_confirm_delete_idx = -1; s_confirm_delete_idx = -1;
Notifications::instance().success(TR("address_book_deleted")); 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. // (pre-connect) — better a visible-everywhere contact than one orphaned to no wallet.
entry.scope = (s_edit_global || activeHash.empty()) ? std::string("global") : activeHash; entry.scope = (s_edit_global || activeHash.empty()) ? std::string("global") : activeHash;
if (isEdit) { 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 (app->addressBook().updateEntry(s_selected_index, entry)) {
if (prevAvatar != entry.avatar) pruneOrphanAvatar(prevAvatar, app->addressBook());
Notifications::instance().success(TR("address_book_updated")); Notifications::instance().success(TR("address_book_updated"));
s_show_edit_dialog = false; s_show_edit_dialog = false;
} else { } else {
@@ -824,8 +848,16 @@ void RenderContactsTab(App* app)
const auto& entry = book.entries()[i]; const auto& entry = book.entries()[i];
ImGui::TableNextRow(); ImGui::TableNextRow();
ImGui::PushID(static_cast<int>(i)); ImGui::PushID(static_cast<int>(i));
const bool shielded = isShieldedAddr(entry.address);
ImGui::TableNextColumn(); ImGui::TableNextColumn();
bool is_selected = (s_selected_index == static_cast<int>(i)); bool is_selected = (s_selected_index == static_cast<int>(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, if (ImGui::Selectable(entry.label.c_str(), is_selected,
ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick)) { ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick)) {
if (s_selected_index != static_cast<int>(i)) s_confirm_delete_idx = -1; if (s_selected_index != static_cast<int>(i)) s_confirm_delete_idx = -1;
@@ -835,8 +867,10 @@ void RenderContactsTab(App* app)
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) {
s_selected_index = static_cast<int>(i); s_confirm_delete_idx = -1; openContextMenu = true; s_selected_index = static_cast<int>(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(); ImGui::TableNextColumn();
bool shielded = isShieldedAddr(entry.address);
ImGui::PushFont(material::Type().subtitle2()); ImGui::PushFont(material::Type().subtitle2());
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(typeColor(shielded)), "%s", shielded ? "Z" : "T"); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(typeColor(shielded)), "%s", shielded ? "Z" : "T");
ImGui::PopFont(); ImGui::PopFont();