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:
@@ -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 <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)
|
||||
{
|
||||
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<int>(i));
|
||||
const bool shielded = isShieldedAddr(entry.address);
|
||||
ImGui::TableNextColumn();
|
||||
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,
|
||||
ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick)) {
|
||||
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)) {
|
||||
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();
|
||||
bool shielded = isShieldedAddr(entry.address);
|
||||
ImGui::PushFont(material::Type().subtitle2());
|
||||
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(typeColor(shielded)), "%s", shielded ? "Z" : "T");
|
||||
ImGui::PopFont();
|
||||
|
||||
Reference in New Issue
Block a user