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();
|
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();
|
||||||
|
|||||||
Reference in New Issue
Block a user