diff --git a/css/tailwind.source.css b/css/tailwind.source.css index 5b97baf0..934d7a6a 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -1618,6 +1618,14 @@ white-space: nowrap; } + .profile-modal__remove-button { + @apply border-2; + } + + .profile-modal__remove-button:focus-visible { + --tw-ring-color: var(--color-critical-strong); + } + .profile-modal__add-profile { --tw-ring-color: var(--color-ring-alert); @apply focus-ring relative flex w-full items-center justify-start gap-4 rounded-xl px-5 py-4 text-left text-text transition duration-base ease-in-out; diff --git a/js/app.js b/js/app.js index 23afb2b2..29aa56bf 100644 --- a/js/app.js +++ b/js/app.js @@ -566,6 +566,10 @@ class Application { authService: this.authService, log: (...args) => this.log(...args), closeAllMoreMenus: (options) => this.closeAllMoreMenus(options), + clipboard: + typeof navigator !== "undefined" && navigator?.clipboard + ? navigator.clipboard + : null, }; const profileModalState = { diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index 6ad6a6a0..cac23d0a 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -1640,6 +1640,173 @@ export class ProfileModalController { return container; } + createViewChannelButton({ targetNpub, displayNpub } = {}) { + const normalizedTarget = + typeof targetNpub === "string" && targetNpub.trim() + ? targetNpub.trim() + : ""; + if (!normalizedTarget) { + return null; + } + + const button = document.createElement("button"); + button.type = "button"; + button.className = "btn-ghost focus-ring text-xs"; + button.textContent = "View channel"; + button.dataset.targetNpub = normalizedTarget; + + if (displayNpub && typeof displayNpub === "string") { + button.setAttribute( + "aria-label", + `View channel ${displayNpub.trim() || normalizedTarget}`, + ); + button.title = `View channel ${displayNpub.trim() || normalizedTarget}`; + } + + button.addEventListener("click", () => { + this.callbacks.onChannelLink(button, this); + }); + + return button; + } + + createCopyNpubButton({ targetNpub, displayNpub } = {}) { + const normalizedTarget = + typeof targetNpub === "string" && targetNpub.trim() + ? targetNpub.trim() + : ""; + if (!normalizedTarget) { + return null; + } + + const button = document.createElement("button"); + button.type = "button"; + button.className = "btn-ghost focus-ring text-xs"; + button.textContent = "Copy npub"; + + if (displayNpub && typeof displayNpub === "string") { + button.setAttribute( + "aria-label", + `Copy ${displayNpub.trim() || normalizedTarget}`, + ); + button.title = `Copy ${displayNpub.trim() || normalizedTarget}`; + } + + const handleCopy = async () => { + if (button.dataset.state === "loading") { + return; + } + + button.dataset.state = "loading"; + button.disabled = true; + button.setAttribute("aria-busy", "true"); + + try { + await this.copyNpubToClipboard(normalizedTarget, { displayNpub }); + } finally { + button.disabled = false; + button.removeAttribute("aria-busy"); + delete button.dataset.state; + } + }; + + button.addEventListener("click", () => { + void handleCopy(); + }); + + return button; + } + + createRemoveButton({ + label = "Remove", + confirmMessage, + confirmValue, + onRemove, + } = {}) { + if (typeof onRemove !== "function") { + return null; + } + + const button = document.createElement("button"); + button.type = "button"; + button.className = "btn-ghost focus-ring text-xs profile-modal__remove-button"; + button.dataset.variant = "critical"; + button.dataset.role = "remove"; + button.textContent = label; + + const handleRemove = async () => { + if (confirmMessage) { + const replacement = + typeof confirmValue === "string" && confirmValue.trim() + ? confirmValue.trim() + : "this entry"; + const prompt = confirmMessage.replace("{npub}", replacement); + if (!window.confirm(prompt)) { + return; + } + } + + button.disabled = true; + button.setAttribute("aria-busy", "true"); + + try { + await onRemove(button); + } catch (error) { + userLogger.error("Failed to remove entry:", error); + } finally { + button.disabled = false; + button.removeAttribute("aria-busy"); + } + }; + + button.addEventListener("click", () => { + void handleRemove(); + }); + + return button; + } + + async copyNpubToClipboard(npub, { displayNpub } = {}) { + const normalized = + typeof npub === "string" && npub.trim() ? npub.trim() : ""; + if (!normalized) { + this.showError("Unable to copy npub. Invalid value provided."); + return { copied: false, reason: "invalid" }; + } + + const clipboard = (() => { + if ( + this.services?.clipboard && + typeof this.services.clipboard.writeText === "function" + ) { + return this.services.clipboard; + } + if ( + typeof navigator !== "undefined" && + navigator?.clipboard && + typeof navigator.clipboard.writeText === "function" + ) { + return navigator.clipboard; + } + return null; + })(); + + if (!clipboard) { + this.showError("Copy to clipboard is not supported in this browser."); + return { copied: false, reason: "unsupported" }; + } + + try { + await clipboard.writeText(normalized); + this.showSuccess("npub copied to clipboard!"); + return { copied: true }; + } catch (error) { + userLogger.error("Failed to copy npub:", error); + this.showError("Failed to copy npub. Please try again."); + return { copied: false, error }; + } + } + setupLayoutBreakpointObserver() { if ( typeof window === "undefined" || @@ -2439,18 +2606,38 @@ export class ProfileModalController { avatarSrc, }); - const actionBtn = document.createElement("button"); - actionBtn.type = "button"; - actionBtn.className = "btn-ghost focus-ring text-xs"; - actionBtn.dataset.variant = "danger"; - actionBtn.textContent = "Remove"; - actionBtn.dataset.blockedHex = hex; - actionBtn.addEventListener("click", () => { - void this.handleRemoveBlockedCreator(hex); + const actions = document.createElement("div"); + actions.className = "flex flex-wrap items-center justify-end gap-2"; + + const viewButton = this.createViewChannelButton({ + targetNpub: encodedNpub, + displayNpub, }); + if (viewButton) { + actions.appendChild(viewButton); + } + + const copyButton = this.createCopyNpubButton({ + targetNpub: encodedNpub, + displayNpub, + }); + if (copyButton) { + actions.appendChild(copyButton); + } + + const removeButton = this.createRemoveButton({ + label: "Remove", + onRemove: () => this.handleRemoveBlockedCreator(hex), + }); + if (removeButton) { + removeButton.dataset.blockedHex = hex; + actions.appendChild(removeButton); + } item.appendChild(summary); - item.appendChild(actionBtn); + if (actions.childElementCount > 0) { + item.appendChild(actions); + } this.blockList.appendChild(item); }); @@ -3636,25 +3823,42 @@ export class ProfileModalController { displayNpub, avatarSrc, }); - item.appendChild(summary); + + const actions = document.createElement("div"); + actions.className = + "flex flex-wrap items-center justify-end gap-2 sm:flex-none"; + + const viewButton = this.createViewChannelButton({ + targetNpub: encodedNpub, + displayNpub, + }); + if (viewButton) { + actions.appendChild(viewButton); + } + + const copyButton = this.createCopyNpubButton({ + targetNpub: encodedNpub, + displayNpub, + }); + if (copyButton) { + actions.appendChild(copyButton); + } if (removable && typeof onRemove === "function") { - const removeBtn = document.createElement("button"); - removeBtn.type = "button"; - removeBtn.className = "btn-ghost focus-ring text-xs"; - removeBtn.textContent = removeLabel; - removeBtn.addEventListener("click", () => { - if (confirmMessage) { - const message = confirmMessage.replace("{npub}", displayNpub); - if (!window.confirm(message)) { - return; - } - } - removeBtn.disabled = true; - removeBtn.setAttribute("aria-busy", "true"); - onRemove(npub, removeBtn); + const removeBtn = this.createRemoveButton({ + label: removeLabel, + confirmMessage, + confirmValue: displayNpub, + onRemove: (button) => onRemove(npub, button), }); - item.appendChild(removeBtn); + if (removeBtn) { + actions.appendChild(removeBtn); + } + } + + item.appendChild(summary); + if (actions.childElementCount > 0) { + item.appendChild(actions); } listEl.appendChild(item); diff --git a/tests/profile-modal-controller.test.mjs b/tests/profile-modal-controller.test.mjs index 4af78fed..67971dad 100644 --- a/tests/profile-modal-controller.test.mjs +++ b/tests/profile-modal-controller.test.mjs @@ -683,7 +683,9 @@ test('admin mutations invoke accessControl stubs and update admin DOM', async () assert.equal(whitelistButton.hasAttribute('aria-busy'), false); assert.ok(successMessages.includes('Added to the whitelist.')); - const removeButton = controller.whitelistList.querySelector('button'); + const removeButton = controller.whitelistList.querySelector( + 'button[data-role="remove"]', + ); await controller.handleAdminListMutation('whitelist', 'remove', 'npub1allow', removeButton); const removeEvent = accessEvents.find((entry) => entry[0] === 'removeWhitelist'); assert.deepEqual(removeEvent, [