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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user