feat(contacts): image avatars are now a managed library grid

Redesign the Image avatar tab from a single-image chooser into a grid over a
persistent image library (mirrors the Icon tab):

- Images the user adds live in <config>/contact-avatars/ and PERSIST as a
  reusable, portable set — they travel with the wallet data dir, so avatars
  survive moving to another machine without remembering source paths.
- The grid's first cell is always the "+ add image" button (opens the picker,
  decode-verifies, copies into the library, auto-selects the new image).
- Each library image is a selectable thumbnail (Primary ring when selected)
  with a delete badge (top-right, red on hover) to remove it from the library;
  the delete is deferred past the grid loop and clears the selection if it
  pointed at the removed file. Contacts still referencing a deleted image fall
  back to their Z/T badge.
- Removes the previous auto-prune of "unused" images on edit/delete — images
  are only removed by the explicit delete badge now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 10:22:35 -05:00
parent f8034b843a
commit 62fc202557

View File

@@ -157,23 +157,50 @@ 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 // Avatar image LIBRARY: the images the user has added live in <config>/contact-avatars/ and persist as
// `oldAvatar`'s reference removed: if `oldAvatar` is an "img:" avatar that no contact still uses AND it // a reusable, portable set (they travel with the wallet data dir). The Image avatar tab is a grid over
// lives inside our managed <config>/contact-avatars/ dir (never touch anything outside it), remove the // this library; images are only removed when the user explicitly deletes them.
// file and drop its cached texture. A no-op for badge/icon avatars or a still-shared image. static std::vector<std::string> s_avatarLibrary; // absolute paths, sorted
static void pruneOrphanAvatar(const std::string& oldAvatar, const data::AddressBook& book) { static bool s_avatarLibraryDirty = true;
if (oldAvatar.rfind("img:", 0) != 0) return;
for (const auto& e : book.entries()) if (e.avatar == oldAvatar) return; // still referenced elsewhere static bool isImageFileName(const std::string& name) {
std::string lo = name;
for (char& c : lo) c = static_cast<char>(std::tolower((unsigned char)c));
for (const char* e : { ".png", ".jpg", ".jpeg", ".bmp", ".gif" }) {
const std::string ext(e);
if (lo.size() > ext.size() && lo.compare(lo.size() - ext.size(), ext.size(), ext) == 0) return true;
}
return false;
}
static void rescanAvatarLibrary() {
s_avatarLibrary.clear();
s_avatarLibraryDirty = false;
namespace fs = std::filesystem;
std::error_code ec;
fs::path dir = fs::path(util::Platform::getConfigDir()) / "contact-avatars";
if (!fs::is_directory(dir, ec)) return;
for (fs::directory_iterator it(dir, ec), end; !ec && it != end; it.increment(ec)) {
std::error_code fec;
if (it->is_regular_file(fec) && isImageFileName(it->path().filename().string()))
s_avatarLibrary.push_back(it->path().string());
}
std::sort(s_avatarLibrary.begin(), s_avatarLibrary.end());
}
// Explicitly delete a library image (user pressed its delete icon). Only touches files directly inside
// the managed dir; drops the cached texture, clears the current selection if it pointed here, and marks
// the library for rescan. Contacts still referencing a deleted image fall back to their Z/T badge.
static void deleteAvatarLibraryFile(const std::string& path) {
namespace fs = std::filesystem; namespace fs = std::filesystem;
std::error_code ec; 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 managed = fs::weakly_canonical(fs::path(util::Platform::getConfigDir()) / "contact-avatars", ec);
fs::path target = fs::weakly_canonical(fs::path(path), ec); fs::path target = fs::weakly_canonical(fs::path(path), ec);
// Only prune files sitting DIRECTLY in the managed dir (copyAvatarImage never nests) — a parent-path if (managed.empty() || target.parent_path() != managed) return; // safety: never escape the dir
// compare avoids string-prefix false matches (e.g. a sibling "contact-avatars-x") and path escapes.
if (managed.empty() || target.parent_path() != managed) return;
fs::remove(target, ec); fs::remove(target, ec);
invalidateAvatarTexture(path); invalidateAvatarTexture(path);
if (s_edit_avatar == "img:" + path) s_edit_avatar.clear();
s_avatarLibraryDirty = true;
} }
void RenderContactsTab(App* app) void RenderContactsTab(App* app)
@@ -199,6 +226,7 @@ void RenderContactsTab(App* app)
s_edit_avatar.clear(); s_edit_avatar.clear();
s_edit_avatar_mode = 0; s_edit_avatar_mode = 0;
s_edit_icon_search[0] = '\0'; s_edit_icon_search[0] = '\0';
s_avatarLibraryDirty = true; // rescan the image library on open (files may have changed)
}; };
auto loadEditFields = [](const data::AddressBookEntry& entry) { auto loadEditFields = [](const data::AddressBookEntry& entry) {
@@ -210,6 +238,7 @@ void RenderContactsTab(App* app)
s_edit_avatar_mode = (entry.avatar.rfind("icon:", 0) == 0) ? 1 s_edit_avatar_mode = (entry.avatar.rfind("icon:", 0) == 0) ? 1
: (entry.avatar.rfind("img:", 0) == 0) ? 2 : 0; : (entry.avatar.rfind("img:", 0) == 0) ? 2 : 0;
s_edit_icon_search[0] = '\0'; s_edit_icon_search[0] = '\0';
s_avatarLibraryDirty = true; // rescan the image library on open (files may have changed)
}; };
bool has_selection = s_selected_index >= 0 && s_selected_index < static_cast<int>(book.size()); bool has_selection = s_selected_index >= 0 && s_selected_index < static_cast<int>(book.size());
@@ -221,9 +250,8 @@ 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; // The contact's avatar image (if any) stays in the library for reuse — not deleted here.
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"));
@@ -539,84 +567,104 @@ void RenderContactsTab(App* app)
ImGui::EndChild(); ImGui::EndChild();
ImGui::PopStyleColor(); ImGui::PopStyleColor();
} else { } else {
// Image: a big circular preview (or a placeholder), the filename, and choose / remove, // Image: a grid over the avatar image LIBRARY. The first cell adds a new image (opens the
// vertically centered in the (now tall) column. // picker + copies it into the library); each library image is selectable and has a delete
const bool hasImg = s_edit_avatar.rfind("img:", 0) == 0; // badge to remove it. Images persist in <config>/contact-avatars/ so they're reusable and
ImDrawList* mdl = ImGui::GetWindowDrawList(); // travel with the wallet data — deleting a contact never removes them.
ImFont* capF = material::Type().caption(); if (s_avatarLibraryDirty) rescanAvatarLibrary();
const float prR = 56.0f * dp; ImGui::PushStyleColor(ImGuiCol_ChildBg, IM_COL32(0, 0, 0, 0));
ImVec2 a = ImGui::GetContentRegionAvail(); ImGui::BeginChild("##avImgGrid", ImVec2(-1, ImGui::GetContentRegionAvail().y), false);
ImVec2 origin = ImGui::GetCursorScreenPos(); ImDrawList* gdl = ImGui::GetWindowDrawList();
float btnH = 40.0f * dp; const float cellGap = 6.0f * dp;
float blockH = prR * 2.0f + Layout::spacingLg() const float availW = ImGui::GetContentRegionAvail().x;
+ (hasImg ? capF->LegacySize + Layout::spacingMd() : 0.0f) + btnH; const int cols = std::max(3, (int)((availW + cellGap) / (74.0f * dp + cellGap)));
float topPad = std::max(Layout::spacingSm(), (a.y - blockH) * 0.32f); const float cell = std::max(48.0f * dp, (availW - cellGap * (cols - 1)) / (float)cols);
float cx = origin.x + a.x * 0.5f; const int total = 1 + (int)s_avatarLibrary.size();
float cy = origin.y + topPad + prR; int pendingDelete = -1;
ImVec2 cc(cx, cy); int col = 0;
if (hasImg) { for (int n = 0; n < total; ++n) {
if (const AvatarTex* t = getAvatarTexture(s_edit_avatar.substr(4))) { if (col != 0) ImGui::SameLine(0, cellGap);
float u0=0,v0=0,u1=1,v1=1; ImVec2 mn = ImGui::GetCursorScreenPos();
if (t->w > t->h) { float m=(t->w-t->h)*0.5f/t->w; u0=m; u1=1-m; } ImVec2 mx(mn.x + cell, mn.y + cell);
else if (t->h > t->w) { float m=(t->h-t->w)*0.5f/t->h; v0=m; v1=1-m; } ImVec2 cc(mn.x + cell * 0.5f, mn.y + cell * 0.5f);
mdl->AddImageRounded(t->tex, ImVec2(cc.x-prR, cc.y-prR), ImVec2(cc.x+prR, cc.y+prR), if (n == 0) {
ImVec2(u0,v0), ImVec2(u1,v1), IM_COL32_WHITE, prR); // "Add image" cell — always first.
mdl->AddCircle(cc, prR, material::WithAlpha(material::OnSurface(), 60), 0, 1.5f * dp); bool hov = ImGui::IsMouseHoveringRect(mn, mx);
} else { gdl->AddRectFilled(mn, mx, material::WithAlpha(material::OnSurface(), hov ? 30 : 14), 6.0f * dp);
// Image set but no longer loadable (moved/deleted) — show a broken-image glyph. gdl->AddRect(mn, mx, material::WithAlpha(material::OnSurface(), hov ? 110 : 55), 6.0f * dp, 0, 1.5f * dp);
mdl->AddCircleFilled(cc, prR, material::WithAlpha(material::ReadableError(), 35));
mdl->AddCircle(cc, prR, material::WithAlpha(material::ReadableError(), 150), 0, 1.5f * dp);
ImFont* gf = material::Type().iconXL(); ImFont* gf = material::Type().iconXL();
float gsz = prR * 0.72f; float gsz = cell * 0.4f;
ImVec2 gs = gf->CalcTextSizeA(gsz, FLT_MAX, 0, ICON_MD_BROKEN_IMAGE); ImVec2 gs = gf->CalcTextSizeA(gsz, FLT_MAX, 0, ICON_MD_ADD_PHOTO_ALTERNATE);
mdl->AddText(gf, gsz, ImVec2(cc.x - gs.x*0.5f, cc.y - gs.y*0.5f), gdl->AddText(gf, gsz, ImVec2(cc.x - gs.x * 0.5f, cc.y - gs.y * 0.5f),
material::ReadableError(), ICON_MD_BROKEN_IMAGE); hov ? material::Primary() : material::OnSurfaceMedium(), ICON_MD_ADD_PHOTO_ALTERNATE);
ImGui::PushID(0);
if (ImGui::InvisibleButton("##avadd", ImVec2(cell, cell))) {
ImagePicker::open("", [](const std::string& src) {
// Verify the source decodes before committing (guards corrupt/unsupported files).
int iw = 0, ih = 0;
unsigned char* px = util::LoadRawPixelsFromFile(src.c_str(), &iw, &ih);
if (!px) { Notifications::instance().error(TR("contact_avatar_bad_image")); return; }
util::FreeRawPixels(px);
std::string dst = copyAvatarImage(src);
if (!dst.empty()) {
invalidateAvatarTexture(dst);
s_edit_avatar = "img:" + dst; s_edit_avatar_mode = 2;
s_avatarLibraryDirty = true; // surface the new image in the grid
} else Notifications::instance().error(TR("contact_avatar_copy_failed"));
});
}
if (ImGui::IsItemHovered()) { ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); material::Tooltip("%s", TR("contact_avatar_choose")); }
ImGui::PopID();
} else {
const std::string& path = s_avatarLibrary[n - 1];
const bool sel = (s_edit_avatar == "img:" + path);
bool hov = ImGui::IsMouseHoveringRect(mn, mx);
gdl->AddRectFilled(mn, mx, material::WithAlpha(material::OnSurface(), 18), 6.0f * dp);
const AvatarTex* t = ImGui::IsRectVisible(mn, mx) ? getAvatarTexture(path) : nullptr;
if (t && t->tex) {
float u0=0,v0=0,u1=1,v1=1; // centre-crop to a square
if (t->w > t->h) { float m=(t->w-t->h)*0.5f/t->w; u0=m; u1=1-m; }
else if (t->h > t->w) { float m=(t->h-t->w)*0.5f/t->h; v0=m; v1=1-m; }
float ins = 2.0f * dp;
gdl->AddImageRounded(t->tex, ImVec2(mn.x+ins, mn.y+ins), ImVec2(mx.x-ins, mx.y-ins),
ImVec2(u0,v0), ImVec2(u1,v1), IM_COL32_WHITE, 5.0f * dp);
} else {
ImFont* gf = material::Type().iconLarge();
ImVec2 gs = gf->CalcTextSizeA(cell * 0.34f, FLT_MAX, 0, ICON_MD_IMAGE);
gdl->AddText(gf, cell * 0.34f, ImVec2(cc.x - gs.x*0.5f, cc.y - gs.y*0.5f),
material::OnSurfaceDisabled(), ICON_MD_IMAGE);
}
if (sel) gdl->AddRect(mn, mx, material::WithAlpha(material::Primary(), 230), 6.0f * dp, 0, 2.5f * dp);
else if (hov) gdl->AddRect(mn, mx, material::WithAlpha(material::OnSurface(), 100), 6.0f * dp, 0, 1.5f * dp);
ImGui::PushID(n);
ImGui::SetNextItemAllowOverlap(); // let the delete badge take clicks over the thumb
if (ImGui::InvisibleButton("##avthumb", ImVec2(cell, cell))) s_edit_avatar = "img:" + path;
bool thumbHov = ImGui::IsItemHovered();
if (thumbHov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImVec2 afterCell = ImGui::GetCursorScreenPos();
if (thumbHov || sel || hov) {
float dr = std::max(7.0f * dp, cell * 0.15f);
ImVec2 dcc(mx.x - dr - 3.0f * dp, mn.y + dr + 3.0f * dp);
bool dhov = ImGui::IsMouseHoveringRect(ImVec2(dcc.x-dr,dcc.y-dr), ImVec2(dcc.x+dr,dcc.y+dr));
gdl->AddCircleFilled(dcc, dr, dhov ? material::ReadableError() : IM_COL32(0, 0, 0, 175));
ImFont* xf = material::Type().iconSmall();
float xsz = dr * 1.35f;
ImVec2 xs = xf->CalcTextSizeA(xsz, FLT_MAX, 0, ICON_MD_CLOSE);
gdl->AddText(xf, xsz, ImVec2(dcc.x - xs.x*0.5f, dcc.y - xs.y*0.5f), IM_COL32(255,255,255,235), ICON_MD_CLOSE);
ImGui::SetCursorScreenPos(ImVec2(dcc.x - dr, dcc.y - dr));
if (ImGui::InvisibleButton("##avdel", ImVec2(dr*2, dr*2))) pendingDelete = n - 1;
if (ImGui::IsItemHovered()) { ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); material::Tooltip("%s", TR("delete")); }
}
ImGui::SetCursorScreenPos(afterCell);
ImGui::PopID();
} }
} else { col = (col + 1) % cols;
mdl->AddCircleFilled(cc, prR, material::WithAlpha(material::OnSurface(), 18));
mdl->AddCircle(cc, prR, material::WithAlpha(material::OnSurface(), 55), 0, 1.5f * dp);
ImFont* gf = material::Type().iconXL();
float gsz = prR * 0.72f;
ImVec2 gs = gf->CalcTextSizeA(gsz, FLT_MAX, 0, ICON_MD_ADD_PHOTO_ALTERNATE);
mdl->AddText(gf, gsz, ImVec2(cc.x - gs.x*0.5f, cc.y - gs.y*0.5f),
material::OnSurfaceDisabled(), ICON_MD_ADD_PHOTO_ALTERNATE);
}
float rowY = cy + prR + Layout::spacingLg(); // clear gap between the preview and the row
if (hasImg) {
std::string fname = std::filesystem::path(s_edit_avatar.substr(4)).filename().string();
std::string fit = fitText(fname, capF, a.x - 8.0f * dp);
ImVec2 fs = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, fit.c_str());
mdl->AddText(capF, capF->LegacySize, ImVec2(cx - fs.x*0.5f, rowY), material::OnSurfaceMedium(), fit.c_str());
rowY += capF->LegacySize + Layout::spacingMd();
}
// Buttons row, centered.
ImGui::SetCursorScreenPos(ImVec2(origin.x, rowY));
float chooseW = btnFont->CalcTextSizeA(btnFont->LegacySize, FLT_MAX, 0, TR("contact_avatar_choose")).x
+ ImGui::GetStyle().FramePadding.x * 2.0f + 20.0f * dp;
float removeW = hasImg ? (btnFont->CalcTextSizeA(btnFont->LegacySize, FLT_MAX, 0, TR("contact_avatar_remove")).x
+ ImGui::GetStyle().FramePadding.x * 2.0f + 20.0f * dp) : 0.0f;
float rowW = chooseW + (hasImg ? removeW + Layout::spacingSm() : 0.0f);
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, (a.x - rowW) * 0.5f));
if (material::TactileButton(TR("contact_avatar_choose"), ImVec2(chooseW, 0), btnFont)) {
ImagePicker::open("", [](const std::string& src) {
// Verify the source actually decodes before committing (guards a corrupt or
// unsupported file from becoming a broken avatar + an orphaned copy on disk).
int iw = 0, ih = 0;
unsigned char* px = util::LoadRawPixelsFromFile(src.c_str(), &iw, &ih);
if (!px) { Notifications::instance().error(TR("contact_avatar_bad_image")); return; }
util::FreeRawPixels(px);
std::string dst = copyAvatarImage(src);
if (!dst.empty()) {
invalidateAvatarTexture(dst); // in case this path was cached before an overwrite
s_edit_avatar = "img:" + dst; s_edit_avatar_mode = 2;
} else Notifications::instance().error(TR("contact_avatar_copy_failed"));
});
}
if (hasImg) {
ImGui::SameLine(0, Layout::spacingSm());
if (material::TactileButton(TR("contact_avatar_remove"), ImVec2(removeW, 0), btnFont))
s_edit_avatar.clear();
} }
ImGui::EndChild();
ImGui::PopStyleColor();
// Deferred: mutate the library only after the grid loop is done.
if (pendingDelete >= 0 && pendingDelete < (int)s_avatarLibrary.size())
deleteAvatarLibraryFile(s_avatarLibrary[pendingDelete]);
} }
ImGui::EndChild(); // ##contactAvatarCol ImGui::EndChild(); // ##contactAvatarCol
@@ -651,11 +699,7 @@ 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 {