diff --git a/src/popup/popup.css b/src/popup/popup.css index 3d7d8a1..22a80ce 100644 --- a/src/popup/popup.css +++ b/src/popup/popup.css @@ -468,6 +468,18 @@ h2 { color: var(--danger); } +.remove-button--undo { + background: rgba(37, 99, 235, 0.1); + color: var(--accent); + font-size: 12px; + font-weight: 800; +} + +.remove-button--undo:hover { + background: rgba(37, 99, 235, 0.2); + color: var(--accent); +} + .confirm-dialog { width: min(318px, calc(100vw - 28px)); padding: 0; diff --git a/src/popup/popup.js b/src/popup/popup.js index 75b08c1..b1edf0b 100644 --- a/src/popup/popup.js +++ b/src/popup/popup.js @@ -65,6 +65,7 @@ let currentUnblockChallenge = null; let currentBlockedRule = null; let currentBlockedUrl = null; let currentTabId = null; +let freshRules = new Set(); function createMemoryStorage() { let state = { blockedSites: [], temporaryUnblocks: {} }; @@ -138,13 +139,31 @@ function render(sites, temporaryUnblocks = currentTemporaryUnblocks) { }; const remove = documentRef.createElement("button"); - remove.className = "remove-button"; + const isFreshRule = freshRules.has(site); + remove.className = isFreshRule ? + "remove-button remove-button--undo" : + "remove-button"; remove.type = "button"; - remove.textContent = "×"; - remove.setAttribute("aria-label", `Remove ${site}`); - remove.title = "Remove blocked site"; + remove.textContent = isFreshRule ? "Undo" : "×"; + remove.setAttribute( + "aria-label", + isFreshRule ? `Undo adding ${site}` : `Remove ${site}` + ); + remove.title = isFreshRule ? + "Undo adding this blocked site" : + "Remove blocked site"; remove.onclick = () => { + if (isFreshRule) { + return removeBlockedSite(site) + .then(() => { + freshRules.delete(site); + }) + .catch((error) => { + setHint(getErrorMessage(error, "Could not remove blocked site."), true); + }); + } + openRemoveDialog(site); }; @@ -184,13 +203,6 @@ function getCurrentUnblockableSite(sites) { return null; } -function save(sites, callback) { - return storage.set({ blockedSites: sites }).then(() => { - render(sites, currentTemporaryUnblocks); - return callback?.(); - }); -} - function removeBlockedSite(site) { const nextSites = [...currentSites]; const nextTemporaryUnblocks = { ...currentTemporaryUnblocks }; @@ -533,14 +545,21 @@ function saveRule(rule, successMessage, duplicateMessage, callback) { return callback?.(); } - return save([...sites, rule], () => { - input.value = ""; - setHint(successMessage); - return callback?.(); - }); + const nextSites = [...sites, rule]; + + return storage + .set({ blockedSites: nextSites }) + .then(() => { + freshRules.add(rule); + render(nextSites, currentTemporaryUnblocks); + input.value = ""; + setHint(successMessage); + return callback?.(); + }); }); } + async function openBlockPage(tabId, site, blockedUrl = "") { if (!tabId) return; diff --git a/tests/unit/popup-css.test.js b/tests/unit/popup-css.test.js index c98ba78..945dadf 100644 --- a/tests/unit/popup-css.test.js +++ b/tests/unit/popup-css.test.js @@ -33,6 +33,12 @@ test("popup renders a compact custom-image summary", async () => { ); }); +test("fresh rule remove action uses undo styling", async () => { + const css = await readFile(new URL("../../src/popup/popup.css", import.meta.url), "utf8"); + + assert.match(css, /\.remove-button--undo\s*{[^}]*background:\s*rgba\(37,\s*99,\s*235,\s*0\.1\);/s); +}); + function getRule(css, selector) { const match = css.match(new RegExp(`${selector}\\s*{([^}]*)}`)); diff --git a/tests/unit/popup-current-site.test.js b/tests/unit/popup-current-site.test.js index 8e70c2d..371cd7c 100644 --- a/tests/unit/popup-current-site.test.js +++ b/tests/unit/popup-current-site.test.js @@ -232,6 +232,253 @@ test("popup falls back to the default preview when saved image data is invalid", assert.equal(elements.imageHint.classList.has("is-error"), true); }); +test("freshly added rules can be undone immediately without challenge", async (t) => { + const { elements, state, cleanup } = await setupPopupTest({ + activeTab: { + id: 42, + url: "https://example.com/" + } + }); + t.after(cleanup); + + elements.siteInput.value = "example.com"; + await elements.ruleForm.onsubmit({ + preventDefault() {} + }); + + const removeButton = elements.list.children[0].children[2]; + assert.equal(removeButton.textContent, "Undo"); + assert.equal(removeButton.className.includes("remove-button--undo"), true); + assert.equal(removeButton["aria-label"], "Undo adding example.com"); + assert.equal(removeButton.title, "Undo adding this blocked site"); + assert.equal(elements.unblockDialog.open, false); + + await removeButton.onclick(); + + assert.deepEqual(state.blockedSites, []); + assert.equal(elements.unblockDialog.open, false); + assert.equal(elements.formHint.textContent, "Blocked site removed."); +}); + +test("multiple fresh rules each get Undo controls and can be undone independently", async (t) => { + const { elements, state, cleanup } = await setupPopupTest({ + activeTab: { + id: 42, + url: "https://example.com/" + } + }); + t.after(cleanup); + + elements.siteInput.value = "example.com"; + await elements.ruleForm.onsubmit({ + preventDefault() {} + }); + + elements.siteInput.value = "openai.com"; + await elements.ruleForm.onsubmit({ + preventDefault() {} + }); + + assert.deepEqual(state.blockedSites, ["example.com", "openai.com"]); + + const firstRemoveButton = elements.list.children[0].children[2]; + const secondRemoveButton = elements.list.children[1].children[2]; + + assert.equal(firstRemoveButton.textContent, "Undo"); + assert.equal(secondRemoveButton.textContent, "Undo"); + assert.equal(firstRemoveButton["aria-label"], "Undo adding example.com"); + assert.equal(secondRemoveButton["aria-label"], "Undo adding openai.com"); + + await firstRemoveButton.onclick(); + + assert.deepEqual(state.blockedSites, ["openai.com"]); + assert.equal(elements.list.children[0].children[2].textContent, "Undo"); +}); + +test("preloaded and duplicate rules retain the challenge-dialog removal path", async (t) => { + const { elements, cleanup } = await setupPopupTest({ + activeTab: { + id: 42, + url: "https://example.com/" + }, + initialState: { + blockedSites: ["example.com"] + } + }); + t.after(cleanup); + + elements.siteInput.value = "example.com"; + await elements.ruleForm.onsubmit({ + preventDefault() {} + }); + + const removeButton = elements.list.children[0].children[2]; + assert.equal(removeButton.textContent, "×"); + assert.equal(removeButton["aria-label"], "Remove example.com"); + assert.equal(removeButton.title, "Remove blocked site"); + assert.equal(elements.formHint.textContent, "This site is already in the list."); + + await removeButton.onclick(); + await Promise.resolve(); + + assert.equal(elements.unblockDialog.open, true); + assert.equal(elements.challengeDialogTitle.textContent, "Remove blocked site?"); +}); + +test("removing a fresh rule clears temporary unblock state without challenge", async (t) => { + const now = 1_700_000_000_000; + const { elements, state, cleanup } = await setupPopupTest({ + activeTab: { + id: 42, + url: "https://example.com/" + }, + now + }); + t.after(cleanup); + + elements.siteInput.value = "example.com"; + await elements.ruleForm.onsubmit({ + preventDefault() {} + }); + + elements.currentUnblockBtn.onclick(); + await Promise.resolve(); + await Promise.resolve(); + answerCurrentChallenge(elements); + await submitUnblockForm(elements); + + assert.deepEqual(state.temporaryUnblocks, { + "example.com": now + 5 * 60 * 1000 + }); + assert.equal(elements.currentBlockStatus.textContent.startsWith("Unblocked until "), true); + + const removeButton = elements.list.children[0].children[2]; + await removeButton.onclick(); + + assert.deepEqual(state.temporaryUnblocks, {}); + assert.deepEqual(state.blockedSites, []); + assert.equal(elements.unblockDialog.open, false); +}); + +test("a re-added rule is fresh again and stays undoable", async (t) => { + const { elements, state, cleanup } = await setupPopupTest({ + activeTab: { + id: 42, + url: "https://example.com/" + } + }); + t.after(cleanup); + + elements.siteInput.value = "example.com"; + await elements.ruleForm.onsubmit({ + preventDefault() {} + }); + + const removeButton = elements.list.children[0].children[2]; + await removeButton.onclick(); + + assert.deepEqual(state.blockedSites, []); + + elements.siteInput.value = "example.com"; + await elements.ruleForm.onsubmit({ + preventDefault() {} + }); + + const readdRemoveButton = elements.list.children[0].children[2]; + assert.equal(readdRemoveButton.textContent, "Undo"); + await readdRemoveButton.onclick(); + assert.deepEqual(state.blockedSites, []); +}); + +test("fresh rule does not stay fresh after reopening popup", async (t) => { + const firstSession = await setupPopupTest({ + activeTab: { + id: 42, + url: "https://example.com/" + } + }); + const { cleanup: cleanupFirstSession } = firstSession; + t.after(() => { + cleanupFirstSession(); + }); + + firstSession.elements.siteInput.value = "example.com"; + await firstSession.elements.ruleForm.onsubmit({ + preventDefault() {} + }); + + assert.deepEqual(firstSession.state.blockedSites, ["example.com"]); + + const { elements, state, cleanup } = await setupPopupTest({ + activeTab: { + id: 42, + url: "https://example.com/" + }, + initialState: firstSession.state + }); + t.after(cleanup); + + const removeButton = elements.list.children[0].children[2]; + assert.equal(removeButton.textContent, "×"); + assert.equal(removeButton.className.includes("remove-button--undo"), false); + assert.equal(removeButton["aria-label"], "Remove example.com"); + assert.equal(removeButton.title, "Remove blocked site"); + + await removeButton.onclick(); + await Promise.resolve(); + + assert.equal(elements.unblockDialog.open, true); + assert.equal(elements.challengeDialogTitle.textContent, "Remove blocked site?"); + assert.deepEqual(state.blockedSites, ["example.com"]); +}); + +test("a failed fresh Undo keeps the rule and retries successfully", async (t) => { + const { elements, chrome, state, cleanup } = await setupPopupTest({ + activeTab: { + id: 42, + url: "https://example.com/" + } + }); + t.after(cleanup); + + elements.siteInput.value = "example.com"; + await elements.ruleForm.onsubmit({ + preventDefault() {} + }); + + const removeButton = elements.list.children[0].children[2]; + const originalSet = chrome.storage.local.set; + const originalLastError = chrome.runtime.lastError; + let shouldFail = true; + + chrome.storage.local.set = (nextState, callback) => { + if (shouldFail) { + shouldFail = false; + chrome.runtime.lastError = { message: "Storage unavailable" }; + callback?.(); + return; + } + + return originalSet(nextState, callback); + }; + + await removeButton.onclick(); + + assert.equal(elements.formHint.textContent, "Storage unavailable"); + assert.equal(elements.formHint.classList.has("is-error"), true); + assert.equal(removeButton.textContent, "Undo"); + assert.equal(removeButton["aria-label"], "Undo adding example.com"); + assert.deepEqual(state.blockedSites, ["example.com"]); + + chrome.storage.local.set = originalSet; + chrome.runtime.lastError = originalLastError; + await removeButton.onclick(); + + assert.deepEqual(state.blockedSites, []); + assert.equal(elements.formHint.classList.has("is-error"), false); + assert.equal(elements.formHint.textContent, "Blocked site removed."); +}); + test("temporarily unblocking a blocked site confirms with block image preview", async (t) => { const now = 1_700_000_000_000; const { elements, chrome, state, cleanup } = await setupPopupTest({