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();
}
// 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
// Avatar image LIBRARY: the images the user has added live in <config>/contact-avatars/ and persist as
// a reusable, portable set (they travel with the wallet data dir). The Image avatar tab is a grid over
// this library; images are only removed when the user explicitly deletes them.
static std::vector<std::string> s_avatarLibrary; // absolute paths, sorted
static bool s_avatarLibraryDirty = true;
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;
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 sitting DIRECTLY in the managed dir (copyAvatarImage never nests) — a parent-path
// compare avoids string-prefix false matches (e.g. a sibling "contact-avatars-x") and path escapes.
if (managed.empty() || target.parent_path() != managed) return;
if (managed.empty() || target.parent_path() != managed) return; // safety: never escape the dir
fs::remove(target, ec);
invalidateAvatarTexture(path);
if (s_edit_avatar == "img:" + path) s_edit_avatar.clear();
s_avatarLibraryDirty = true;
}
void RenderContactsTab(App* app)
@@ -199,6 +226,7 @@ void RenderContactsTab(App* app)
s_edit_avatar.clear();
s_edit_avatar_mode = 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) {
@@ -210,6 +238,7 @@ void RenderContactsTab(App* app)
s_edit_avatar_mode = (entry.avatar.rfind("icon:", 0) == 0) ? 1
: (entry.avatar.rfind("img:", 0) == 0) ? 2 : 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());
@@ -221,9 +250,8 @@ 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;
// The contact's avatar image (if any) stays in the library for reuse — not deleted here.
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"));
@@ -539,84 +567,104 @@ void RenderContactsTab(App* app)
ImGui::EndChild();
ImGui::PopStyleColor();
} else {
// Image: a big circular preview (or a placeholder), the filename, and choose / remove,
// vertically centered in the (now tall) column.
const bool hasImg = s_edit_avatar.rfind("img:", 0) == 0;
ImDrawList* mdl = ImGui::GetWindowDrawList();
ImFont* capF = material::Type().caption();
const float prR = 56.0f * dp;
ImVec2 a = ImGui::GetContentRegionAvail();
ImVec2 origin = ImGui::GetCursorScreenPos();
float btnH = 40.0f * dp;
float blockH = prR * 2.0f + Layout::spacingLg()
+ (hasImg ? capF->LegacySize + Layout::spacingMd() : 0.0f) + btnH;
float topPad = std::max(Layout::spacingSm(), (a.y - blockH) * 0.32f);
float cx = origin.x + a.x * 0.5f;
float cy = origin.y + topPad + prR;
ImVec2 cc(cx, cy);
if (hasImg) {
if (const AvatarTex* t = getAvatarTexture(s_edit_avatar.substr(4))) {
float u0=0,v0=0,u1=1,v1=1;
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; }
mdl->AddImageRounded(t->tex, ImVec2(cc.x-prR, cc.y-prR), ImVec2(cc.x+prR, cc.y+prR),
ImVec2(u0,v0), ImVec2(u1,v1), IM_COL32_WHITE, prR);
mdl->AddCircle(cc, prR, material::WithAlpha(material::OnSurface(), 60), 0, 1.5f * dp);
} else {
// Image set but no longer loadable (moved/deleted) — show a broken-image glyph.
mdl->AddCircleFilled(cc, prR, material::WithAlpha(material::ReadableError(), 35));
mdl->AddCircle(cc, prR, material::WithAlpha(material::ReadableError(), 150), 0, 1.5f * dp);
// Image: a grid over the avatar image LIBRARY. The first cell adds a new image (opens the
// picker + copies it into the library); each library image is selectable and has a delete
// badge to remove it. Images persist in <config>/contact-avatars/ so they're reusable and
// travel with the wallet data — deleting a contact never removes them.
if (s_avatarLibraryDirty) rescanAvatarLibrary();
ImGui::PushStyleColor(ImGuiCol_ChildBg, IM_COL32(0, 0, 0, 0));
ImGui::BeginChild("##avImgGrid", ImVec2(-1, ImGui::GetContentRegionAvail().y), false);
ImDrawList* gdl = ImGui::GetWindowDrawList();
const float cellGap = 6.0f * dp;
const float availW = ImGui::GetContentRegionAvail().x;
const int cols = std::max(3, (int)((availW + cellGap) / (74.0f * dp + cellGap)));
const float cell = std::max(48.0f * dp, (availW - cellGap * (cols - 1)) / (float)cols);
const int total = 1 + (int)s_avatarLibrary.size();
int pendingDelete = -1;
int col = 0;
for (int n = 0; n < total; ++n) {
if (col != 0) ImGui::SameLine(0, cellGap);
ImVec2 mn = ImGui::GetCursorScreenPos();
ImVec2 mx(mn.x + cell, mn.y + cell);
ImVec2 cc(mn.x + cell * 0.5f, mn.y + cell * 0.5f);
if (n == 0) {
// "Add image" cell — always first.
bool hov = ImGui::IsMouseHoveringRect(mn, mx);
gdl->AddRectFilled(mn, mx, material::WithAlpha(material::OnSurface(), hov ? 30 : 14), 6.0f * dp);
gdl->AddRect(mn, mx, material::WithAlpha(material::OnSurface(), hov ? 110 : 55), 6.0f * dp, 0, 1.5f * dp);
ImFont* gf = material::Type().iconXL();
float gsz = prR * 0.72f;
ImVec2 gs = gf->CalcTextSizeA(gsz, FLT_MAX, 0, ICON_MD_BROKEN_IMAGE);
mdl->AddText(gf, gsz, ImVec2(cc.x - gs.x*0.5f, cc.y - gs.y*0.5f),
material::ReadableError(), ICON_MD_BROKEN_IMAGE);
float gsz = cell * 0.4f;
ImVec2 gs = gf->CalcTextSizeA(gsz, FLT_MAX, 0, ICON_MD_ADD_PHOTO_ALTERNATE);
gdl->AddText(gf, gsz, ImVec2(cc.x - gs.x * 0.5f, cc.y - gs.y * 0.5f),
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 {
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();
col = (col + 1) % cols;
}
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
@@ -651,11 +699,7 @@ 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 {