diff --git a/src/ui/windows/contacts_tab.cpp b/src/ui/windows/contacts_tab.cpp index 08aa83a..8552559 100644 --- a/src/ui/windows/contacts_tab.cpp +++ b/src/ui/windows/contacts_tab.cpp @@ -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 /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 /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 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(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(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 /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 {