diff --git a/Extensions/combined/src/bar.js b/Extensions/combined/src/bar.js index 1df12be..e4596c0 100644 --- a/Extensions/combined/src/bar.js +++ b/Extensions/combined/src/bar.js @@ -1,5 +1,5 @@ import { getButtons, getDislikeButton, getLikeButton } from "./buttons"; -import { extConfig, isMobile, isLikesDisabled, isNewDesign, isRoundedDesign, isShorts } from "./state"; +import { extConfig, isMobile, isLikesDisabled, isNewDesign, isRoundedDesign, isShorts, isVideoLiked } from "./state"; import { getColorFromTheme, getElementWidth, getVideoId, isInViewport, querySelector } from "./utils"; function closestConfigured(element, selectors) { @@ -147,6 +147,7 @@ function createRateBar(likes, dislikes) {
({ isNewDesign: jest.fn(() => true), isRoundedDesign: jest.fn(() => true), isShorts: jest.fn(() => false), + isVideoLiked: jest.fn(() => false), })); jest.mock("./utils", () => ({ @@ -48,8 +49,8 @@ jest.mock("./utils", () => ({ const { getButtons, getDislikeButton, getLikeButton } = require("./buttons"); const { createRateBar, hasUsableRateBar } = require("./bar"); -const { isLikesDisabled, isMobile, isNewDesign, isRoundedDesign, isShorts } = require("./state"); -const { getVideoId } = require("./utils"); +const { isLikesDisabled, isMobile, isNewDesign, isRoundedDesign, isShorts, isVideoLiked } = require("./state"); +const { getVideoId, isInViewport } = require("./utils"); describe("rate-bar stylesheet contrast", () => { test("uses the same high-contrast negative track recipe as the userscript", () => { @@ -95,6 +96,7 @@ describe("rate-bar visual ownership", () => { isNewDesign.mockReturnValue(true); isRoundedDesign.mockReturnValue(true); isShorts.mockReturnValue(false); + isVideoLiked.mockReturnValue(false); getVideoId.mockReturnValue("BBBBBBBBBBB"); const buttons = document.querySelector('[data-watch-buttons="BBBBBBBBBBB"]'); getButtons.mockReturnValue(buttons); @@ -102,6 +104,32 @@ describe("rate-bar visual ownership", () => { getDislikeButton.mockReturnValue(buttons.querySelector("dislike-button-view-model")); }); + test.each([false, true])("initializes the custom CSS hook from the native liked state: %s", (liked) => { + isVideoLiked.mockReturnValue(liked); + + createRateBar(80, 20); + + expect(getButtons().querySelector("#ryd-bar-container").getAttribute("data-ryd-liked")).toBe(String(liked)); + }); + + test("refreshes the liked hook when reusing or recreating the bar", () => { + isInViewport.mockReturnValue(true); + createRateBar(80, 20); + const originalBar = getButtons().querySelector("#ryd-bar-container"); + isVideoLiked.mockReturnValue(true); + + createRateBar(81, 20); + + expect(getButtons().querySelector("#ryd-bar-container")).toBe(originalBar); + expect(originalBar.getAttribute("data-ryd-liked")).toBe("true"); + + originalBar.closest(".ryd-tooltip").remove(); + createRateBar(81, 20); + const recreatedBar = getButtons().querySelector("#ryd-bar-container"); + expect(recreatedBar).not.toBe(originalBar); + expect(recreatedBar.getAttribute("data-ryd-liked")).toBe("true"); + }); + test("mutates only the selected current watch tree when stale duplicate IDs come first", () => { const currentButtons = document.querySelector('[data-watch-buttons="BBBBBBBBBBB"]'); expect(currentButtons.closest("#top-row")).toBe(document.querySelector('[data-watch-row="BBBBBBBBBBB"]')); @@ -160,6 +188,7 @@ describe("rate-bar visual ownership", () => { expect(currentButtons.querySelector("#ryd-bar").style.width).toBe("85.99%"); getVideoId.mockReturnValue("AAAAAAAAAAA"); + isVideoLiked.mockReturnValue(true); expect(hasUsableRateBar(currentButtons)).toBe(false); createRateBar(3_591, 1_409); @@ -168,6 +197,7 @@ describe("rate-bar visual ownership", () => { expect(staleWrapper.isConnected).toBe(false); expect(currentWrapper).not.toBe(staleWrapper); expect(currentWrapper.getAttribute("data-ryd-video-id")).toBe("AAAAAAAAAAA"); + expect(currentWrapper.querySelector("#ryd-bar-container").getAttribute("data-ryd-liked")).toBe("true"); expect(currentButtons.querySelector("#ryd-bar").style.width).toBe("71.82%"); expect(hasUsableRateBar(currentButtons)).toBe(true); }); diff --git a/Extensions/combined/src/events.js b/Extensions/combined/src/events.js index d6bf75c..2916b63 100644 --- a/Extensions/combined/src/events.js +++ b/Extensions/combined/src/events.js @@ -111,7 +111,7 @@ function observeReactionPresentation(likeButton, dislikeButton) { presentationObserver.disconnect(); const options = { attributes: true, - attributeFilter: ["aria-label", "aria-pressed"], + attributeFilter: ["aria-label", "aria-pressed", "class"], characterData: true, childList: true, subtree: true, diff --git a/Extensions/combined/src/reaction-presentation.spec.js b/Extensions/combined/src/reaction-presentation.spec.js index 81cf1e1..b6e71d2 100644 --- a/Extensions/combined/src/reaction-presentation.spec.js +++ b/Extensions/combined/src/reaction-presentation.spec.js @@ -17,6 +17,7 @@ import { NEUTRAL_STATE, } from "./state"; +const { createRateBar: renderRateBar } = jest.requireActual("./bar"); const innerTextDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "innerText"); beforeAll(() => { @@ -148,6 +149,89 @@ test("native count markup is untouched when reformatting is off", async () => { expect(node.data).toBe("731"); }); +test("an initially liked video exposes its native state as soon as the bar is created", () => { + const controls = mount(); + controls.nativeLikeButton.setAttribute("aria-pressed", "true"); + + renderRateBar(storedData.likes, storedData.dislikes); + + expect(document.querySelector("#ryd-bar-container").getAttribute("data-ryd-liked")).toBe("true"); +}); + +test("native like, unlike, and dislike transitions update the CSS hook without changing bar styling", async () => { + const controls = mount(); + renderRateBar(storedData.likes, storedData.dislikes); + const bar = document.querySelector("#ryd-bar-container"); + const barStyle = bar.getAttribute("style"); + const fillStyle = bar.querySelector("#ryd-bar").getAttribute("style"); + expect(bar.getAttribute("data-ryd-liked")).toBe("false"); + refreshReactionPresentation(); + createRateBar.mockClear(); + + for (const [liked, disliked] of [ + [true, false], + [false, false], + [true, false], + [false, true], + ]) { + controls.nativeLikeButton.setAttribute("aria-pressed", String(liked)); + controls.nativeDislikeButton.setAttribute("aria-pressed", String(disliked)); + await flushMutations(); + + expect(bar.getAttribute("data-ryd-liked")).toBe(String(liked)); + expect(bar.getAttribute("style")).toBe(barStyle); + expect(bar.querySelector("#ryd-bar").getAttribute("style")).toBe(fillStyle); + } + expect(storedData.likes).toBe(730); + expect(createRateBar).not.toHaveBeenCalled(); +}); + +test("a replaced native like button refreshes the bar's liked hook", async () => { + const controls = mount(); + renderRateBar(storedData.likes, storedData.dislikes); + const replacement = controls.nativeLikeButton.cloneNode(true); + replacement.setAttribute("aria-pressed", "true"); + + controls.nativeLikeButton.replaceWith(replacement); + await flushMutations(); + + expect(document.querySelector("#ryd-bar-container").getAttribute("data-ryd-liked")).toBe("true"); +}); + +test("native active-class transitions refresh the bar's liked hook", async () => { + const controls = mount(); + renderRateBar(storedData.likes, storedData.dislikes); + const bar = document.querySelector("#ryd-bar-container"); + + controls.likeButton.classList.add("style-default-active"); + await flushMutations(); + expect(bar.getAttribute("data-ryd-liked")).toBe("true"); + + controls.likeButton.classList.remove("style-default-active"); + await flushMutations(); + expect(bar.getAttribute("data-ryd-liked")).toBe("false"); +}); + +test("navigation on reused controls replaces the outgoing bar's liked state", async () => { + const controls = mount(); + controls.nativeLikeButton.setAttribute("aria-pressed", "true"); + renderRateBar(storedData.likes, storedData.dislikes); + const outgoingBar = document.querySelector("#ryd-bar-container"); + expect(outgoingBar.getAttribute("data-ryd-liked")).toBe("true"); + + history.replaceState({}, "", "/watch?v=zyxwvutsrqp"); + Object.assign(storedData, { videoId: "zyxwvutsrqp", likes: 3591, dislikes: 1409 }); + controls.nativeLikeButton.setAttribute("aria-pressed", "false"); + renderRateBar(storedData.likes, storedData.dislikes); + const destinationBar = document.querySelector("#ryd-bar-container"); + expect(destinationBar).not.toBe(outgoingBar); + expect(destinationBar.getAttribute("data-ryd-liked")).toBe("false"); + + controls.nativeLikeButton.setAttribute("aria-pressed", "true"); + await flushMutations(); + expect(destinationBar.getAttribute("data-ryd-liked")).toBe("true"); +}); + test("later native labels refresh stored likes and the rate bar when reformatting is off", async () => { const controls = mount(); refreshReactionPresentation(); diff --git a/Extensions/combined/src/state.js b/Extensions/combined/src/state.js index 0d43a6c..9fcee4e 100644 --- a/Extensions/combined/src/state.js +++ b/Extensions/combined/src/state.js @@ -281,6 +281,11 @@ function setLikes(likesCount) { } function refreshReactionPresentation() { + const rateBar = getButtons()?.querySelector("#ryd-bar-container"); + if (rateBar) { + const liked = String(isVideoLiked()); + if (rateBar.getAttribute("data-ryd-liked") !== liked) rateBar.setAttribute("data-ryd-liked", liked); + } const nativeLikeCount = readNativeLikeCount(); const nativeLikes = nativeLikeCount?.likes ?? false; if (nativeLikes > 0 && nativeLikeCount.videoId === storedData.videoId && !nativeLikeCount.presented) {