mirror of
https://github.com/Anarios/return-youtube-dislike.git
synced 2026-10-27 23:31:06 +01:00
Match native dislike text layout in userscript (#1291)
This commit is contained in:
@@ -1739,20 +1739,38 @@ function getDislikeTextContainer(dislikeButton = getDislikeButton()) {
|
||||
let result =
|
||||
dislikeButton.querySelector("#text") ??
|
||||
dislikeButton.getElementsByTagName("yt-formatted-string")[0] ??
|
||||
dislikeButton.querySelector("span[role='text']");
|
||||
dislikeButton.querySelector("span[role='text']") ??
|
||||
dislikeButton.querySelector(
|
||||
".ytSpecButtonShapeNextButtonTextContent, .yt-spec-button-shape-next__button-text-content",
|
||||
);
|
||||
if (result == null) {
|
||||
const activationTarget = getActivationTarget(dislikeButton);
|
||||
if (!activationTarget?.matches("button, tp-yt-paper-button#button")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const textSpan = document.createElement("span");
|
||||
textSpan.id = "text";
|
||||
textSpan.setAttribute("role", "text");
|
||||
textSpan.style.marginLeft = "6px";
|
||||
activationTarget.appendChild(textSpan);
|
||||
activationTarget.style.width = "auto";
|
||||
result = textSpan;
|
||||
const shorts = isShorts();
|
||||
const usesNativeShape =
|
||||
!shorts &&
|
||||
activationTarget.matches(
|
||||
".ytSpecButtonShapeNextHost, .yt-spec-button-shape-next, " +
|
||||
".ytSpecButtonShapeNextIconButton, .yt-spec-button-shape-next--icon-button, " +
|
||||
".ytSpecButtonShapeNextIconLeading, .yt-spec-button-shape-next--icon-leading",
|
||||
);
|
||||
const text = document.createElement(usesNativeShape ? "div" : "span");
|
||||
text.id = "text";
|
||||
text.setAttribute("role", "text");
|
||||
if (usesNativeShape) {
|
||||
text.className = "ytSpecButtonShapeNextButtonTextContent yt-spec-button-shape-next__button-text-content";
|
||||
activationTarget.classList.remove("ytSpecButtonShapeNextIconButton", "yt-spec-button-shape-next--icon-button");
|
||||
activationTarget.classList.add("ytSpecButtonShapeNextIconLeading", "yt-spec-button-shape-next--icon-leading");
|
||||
} else {
|
||||
if (shorts) text.style.marginLeft = "6px";
|
||||
else text.style.marginInlineStart = "6px";
|
||||
activationTarget.style.width = "auto";
|
||||
}
|
||||
activationTarget.appendChild(text);
|
||||
result = text;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -2278,7 +2296,10 @@ function clearWatchPresentation(buttons, dislikeButton) {
|
||||
const dislikeText =
|
||||
dislikeButton?.querySelector("#text") ??
|
||||
dislikeButton?.getElementsByTagName("yt-formatted-string")[0] ??
|
||||
dislikeButton?.querySelector("span[role='text']");
|
||||
dislikeButton?.querySelector("span[role='text']") ??
|
||||
dislikeButton?.querySelector(
|
||||
".ytSpecButtonShapeNextButtonTextContent, .yt-spec-button-shape-next__button-text-content",
|
||||
);
|
||||
if (dislikeText) {
|
||||
dislikeText.textContent = "";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const {
|
||||
CREDENTIAL_KEY,
|
||||
VIDEO_A,
|
||||
VIDEO_B,
|
||||
createFakeBackend,
|
||||
injectGeneratedUserscript,
|
||||
installGmEnvironment,
|
||||
installHermeticRoutes,
|
||||
openWatchFixture,
|
||||
} = require("./harness");
|
||||
|
||||
// This fixture uses the native shape classes to model icon-only and text-button spacing.
|
||||
function installNativeWatchTextLayout({ shape = "current", direction = "ltr" } = {}) {
|
||||
document.documentElement.dir = direction;
|
||||
const current = shape === "current";
|
||||
const shapeClasses = {
|
||||
host: current ? "ytSpecButtonShapeNextHost" : "yt-spec-button-shape-next",
|
||||
iconOnly: current ? "ytSpecButtonShapeNextIconButton" : "yt-spec-button-shape-next--icon-button",
|
||||
iconLeading: current ? "ytSpecButtonShapeNextIconLeading" : "yt-spec-button-shape-next--icon-leading",
|
||||
icon: current ? "ytSpecButtonShapeNextIcon" : "yt-spec-button-shape-next__icon",
|
||||
text: current ? "ytSpecButtonShapeNextButtonTextContent" : "yt-spec-button-shape-next__button-text-content",
|
||||
};
|
||||
|
||||
if (!document.getElementById("fixture-native-watch-text-style")) {
|
||||
const style = document.createElement("style");
|
||||
style.id = "fixture-native-watch-text-style";
|
||||
style.textContent = `
|
||||
#fixture-watch #top-level-buttons-computed,
|
||||
#fixture-watch [data-fixture-smartimation-content] { width: max-content; }
|
||||
#fixture-watch [data-ryd-role="like"],
|
||||
#fixture-watch [data-ryd-role="dislike"] { width: auto; flex: 0 0 auto; }
|
||||
#fixture-watch [data-ryd-role] button {
|
||||
display: inline-flex; position: relative; align-items: center; justify-content: center;
|
||||
gap: 0; width: auto; min-width: 0; padding: 0 16px; height: 36px;
|
||||
}
|
||||
#fixture-watch [data-ryd-role] button:is(.ytSpecButtonShapeNextIconButton, .yt-spec-button-shape-next--icon-button) {
|
||||
width: 40px; padding: 0;
|
||||
}
|
||||
#fixture-watch [data-ryd-role] :is(.ytSpecButtonShapeNextIcon, .yt-spec-button-shape-next__icon) {
|
||||
display: flex; flex: 0 0 20px; width: 20px; height: 20px; margin: 0;
|
||||
}
|
||||
#fixture-watch [data-ryd-role] button:is(.ytSpecButtonShapeNextIconLeading, .yt-spec-button-shape-next--icon-leading)
|
||||
> :is(.ytSpecButtonShapeNextIcon, .yt-spec-button-shape-next__icon) {
|
||||
margin-inline-start: -6px; margin-inline-end: 6px;
|
||||
}
|
||||
#fixture-watch [data-ryd-role] :is(.ytSpecButtonShapeNextButtonTextContent, .yt-spec-button-shape-next__button-text-content) {
|
||||
display: block; line-height: 20px; white-space: nowrap;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
for (const role of ["like", "dislike"]) {
|
||||
const host = document.querySelector(`#fixture-watch [data-ryd-role="${role}"]`);
|
||||
const button = host.querySelector("button");
|
||||
const svg = button.querySelector("svg");
|
||||
let icon = svg.parentElement;
|
||||
if (icon === button) {
|
||||
icon = document.createElement("span");
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
svg.replaceWith(icon);
|
||||
icon.appendChild(svg);
|
||||
}
|
||||
icon.className = shapeClasses.icon;
|
||||
button.className = `${shapeClasses.host} ${role === "like" ? shapeClasses.iconLeading : shapeClasses.iconOnly}`;
|
||||
if (role === "like") {
|
||||
const text = button.querySelector(".ytSpecButtonShapeNextButtonTextContent, #text");
|
||||
text.textContent = text.textContent.trim();
|
||||
text.className = shapeClasses.text;
|
||||
text.id = "text";
|
||||
text.setAttribute("role", "text");
|
||||
} else {
|
||||
for (const text of button.querySelectorAll("#text")) text.remove();
|
||||
}
|
||||
}
|
||||
globalThis.__nativeWatchShape = shapeClasses;
|
||||
}
|
||||
|
||||
async function expectNativeCountLayout(page) {
|
||||
const layout = await page.evaluate(() => {
|
||||
const rect = (element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
return { left: bounds.left, right: bounds.right, top: bounds.top, bottom: bounds.bottom, width: bounds.width };
|
||||
};
|
||||
const direction = document.documentElement.dir;
|
||||
return ["like", "dislike"].map((role) => {
|
||||
const host = document.querySelector(`#fixture-watch [data-ryd-role="${role}"]`);
|
||||
const button = host.querySelector("button");
|
||||
const icon = rect(button.querySelector("svg"));
|
||||
const text = button.querySelector("#text");
|
||||
const count = rect(text);
|
||||
const bounds = rect(button);
|
||||
return {
|
||||
button: bounds,
|
||||
icon,
|
||||
count,
|
||||
gap: direction === "rtl" ? icon.left - count.right : count.left - icon.right,
|
||||
startPadding: direction === "rtl" ? bounds.right - icon.right : icon.left - bounds.left,
|
||||
endPadding: direction === "rtl" ? count.left - bounds.left : bounds.right - count.right,
|
||||
textFont: getComputedStyle(text).font,
|
||||
textTag: text.tagName,
|
||||
nativeTextClass: text.classList.contains(globalThis.__nativeWatchShape.text),
|
||||
countContainers: host.querySelectorAll("#text").length,
|
||||
};
|
||||
});
|
||||
});
|
||||
const [like, dislike] = layout;
|
||||
expect(dislike.textTag).toBe("DIV");
|
||||
expect(dislike.nativeTextClass).toBe(true);
|
||||
expect(dislike.countContainers).toBe(1);
|
||||
expect(dislike.count.width).toBeGreaterThan(0);
|
||||
expect(dislike.gap).toBeGreaterThan(0);
|
||||
expect(dislike.gap).toBeCloseTo(like.gap, 1);
|
||||
expect(dislike.startPadding).toBeCloseTo(like.startPadding, 1);
|
||||
expect(dislike.endPadding).toBeCloseTo(like.endPadding, 1);
|
||||
expect(dislike.textFont).toBe(like.textFont);
|
||||
expect(dislike.count.left).toBeGreaterThanOrEqual(dislike.button.left);
|
||||
expect(dislike.count.right).toBeLessThanOrEqual(dislike.button.right);
|
||||
expect(Math.abs(dislike.icon.top + dislike.icon.bottom - dislike.count.top - dislike.count.bottom)).toBeLessThan(1);
|
||||
}
|
||||
|
||||
for (const shape of ["current", "older"]) {
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
test(`${shape} watch fallback matches native text layout in ${direction} across updates and navigation`, async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
const pageErrors = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(error.message));
|
||||
const backend = createFakeBackend({
|
||||
countsByVideo: { [VIDEO_A]: { likes: 100, dislikes: 25 }, [VIDEO_B]: { likes: 200, dislikes: 37 } },
|
||||
fixture: { nativeDislikeText: false },
|
||||
});
|
||||
await installGmEnvironment(context, {
|
||||
[CREDENTIAL_KEY]: { registrationConfirmed: true, userId: "ExistingUserscriptCredential00000001" },
|
||||
});
|
||||
await installHermeticRoutes(context, backend);
|
||||
await openWatchFixture(page, VIDEO_A);
|
||||
await page.evaluate(installNativeWatchTextLayout, { shape, direction });
|
||||
await page.evaluate(() => {
|
||||
const host = document.querySelector('#fixture-watch [data-ryd-role="dislike"]');
|
||||
globalThis.__nativeWatchOriginal = {
|
||||
button: host.querySelector("button"),
|
||||
icon: host.querySelector("svg"),
|
||||
feedback: host.querySelector("[data-fixture-touch-feedback]"),
|
||||
};
|
||||
});
|
||||
await injectGeneratedUserscript(page);
|
||||
|
||||
const dislike = page.locator('#fixture-watch [data-ryd-role="dislike"]');
|
||||
await expect(dislike.locator("#text")).toHaveText("25");
|
||||
await expectNativeCountLayout(page);
|
||||
expect(
|
||||
await page.evaluate(() => {
|
||||
const host = document.querySelector('#fixture-watch [data-ryd-role="dislike"]');
|
||||
const original = globalThis.__nativeWatchOriginal;
|
||||
globalThis.__nativeWatchFirstCount = host.querySelector("#text");
|
||||
return (
|
||||
host.querySelector("button") === original.button &&
|
||||
host.querySelector("svg") === original.icon &&
|
||||
host.querySelector("[data-fixture-touch-feedback]") === original.feedback
|
||||
);
|
||||
}),
|
||||
).toBe(true);
|
||||
await expect(dislike.locator("button")).toHaveAttribute("aria-label", "Dislike this video");
|
||||
await expect(dislike.locator("button")).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
await dislike.locator("#text").click();
|
||||
await expect(dislike.locator("#text")).toHaveText("26");
|
||||
await expect.poll(() => backend.requestsFor("POST", "/interact/confirmVote").length).toBe(1);
|
||||
await expectNativeCountLayout(page);
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.querySelector('#fixture-watch [data-ryd-role="dislike"] #text') ===
|
||||
globalThis.__nativeWatchFirstCount,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const host = document.querySelector('#fixture-watch [data-ryd-role="dislike"]');
|
||||
const replacement = host.cloneNode(true);
|
||||
replacement.querySelector("#text").remove();
|
||||
const button = replacement.querySelector("button");
|
||||
button.className = `${globalThis.__nativeWatchShape.host} ${globalThis.__nativeWatchShape.iconOnly}`;
|
||||
host.replaceWith(replacement);
|
||||
});
|
||||
await expect(dislike.locator("#text")).toHaveText("26");
|
||||
await expectNativeCountLayout(page);
|
||||
expect(backend.requestsFor("GET", "/votes")).toHaveLength(1);
|
||||
|
||||
await page.evaluate(({ videoId }) => globalThis.__youtubeFixture.navigate(videoId), { videoId: VIDEO_B });
|
||||
await page.evaluate(installNativeWatchTextLayout, { shape, direction });
|
||||
await expect(dislike.locator("#text")).toHaveText("37");
|
||||
await expectNativeCountLayout(page);
|
||||
await expect(dislike.locator("button")).toHaveAttribute("aria-pressed", "false");
|
||||
expect(backend.requestsFor("GET", "/votes").map(({ query }) => query.videoId)).toEqual([VIDEO_A, VIDEO_B]);
|
||||
expect(
|
||||
backend.requestsFor("POST", "/interact/vote").map(({ body }) => ({ videoId: body.videoId, value: body.value })),
|
||||
).toEqual([{ videoId: VIDEO_A, value: -1 }]);
|
||||
expect(backend.blockedRequests).toEqual([]);
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test(`${shape} class-only native count clears during navigation and reuses its node for the next video`, async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
const backend = createFakeBackend({
|
||||
countsByVideo: { [VIDEO_A]: { likes: 100, dislikes: 25 }, [VIDEO_B]: { likes: 200, dislikes: 37 } },
|
||||
fixture: { nativeDislikeText: false },
|
||||
});
|
||||
await installGmEnvironment(context, {
|
||||
[CREDENTIAL_KEY]: { registrationConfirmed: true, userId: "ExistingUserscriptCredential00000001" },
|
||||
});
|
||||
await installHermeticRoutes(context, backend);
|
||||
await openWatchFixture(page, VIDEO_A);
|
||||
await page.evaluate(installNativeWatchTextLayout, { shape });
|
||||
await page.evaluate(() => {
|
||||
const button = document.querySelector('#fixture-watch [data-ryd-role="dislike"] button');
|
||||
const count = document.createElement("div");
|
||||
count.className = globalThis.__nativeWatchShape.text;
|
||||
count.setAttribute("data-fixture-native-count", "true");
|
||||
button.className = `${globalThis.__nativeWatchShape.host} ${globalThis.__nativeWatchShape.iconLeading}`;
|
||||
button.appendChild(count);
|
||||
globalThis.__nativeWatchExistingCount = count;
|
||||
});
|
||||
await injectGeneratedUserscript(page);
|
||||
const count = page.locator("[data-fixture-native-count]");
|
||||
await expect(count).toHaveText("25");
|
||||
await expect(count).not.toHaveAttribute("id");
|
||||
await expect(count).not.toHaveAttribute("role");
|
||||
|
||||
const nextCounts = backend.defer("GET", "/votes");
|
||||
await page.evaluate((videoId) => {
|
||||
document.dispatchEvent(new Event("yt-navigate-start", { bubbles: true }));
|
||||
history.pushState({}, "", `/watch?v=${videoId}`);
|
||||
document.querySelector("#fixture-watch").setAttribute("video-id", videoId);
|
||||
for (const role of ["like", "dislike"]) {
|
||||
const button = document.querySelector(`#fixture-watch [data-ryd-role="${role}"] button`);
|
||||
button.setAttribute("data-video-id", videoId);
|
||||
button.setAttribute("aria-label", role === "like" ? "200 likes" : "Dislike the next video");
|
||||
}
|
||||
document.dispatchEvent(new Event("yt-navigate-finish", { bubbles: true }));
|
||||
}, VIDEO_B);
|
||||
await nextCounts.seen;
|
||||
await expect(count).toHaveText("");
|
||||
nextCounts.release({ body: { likes: 200, dislikes: 37 } });
|
||||
await expect(count).toHaveText("37");
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.querySelector("[data-fixture-native-count]") === globalThis.__nativeWatchExistingCount,
|
||||
),
|
||||
).toBe(true);
|
||||
await expect(count).not.toHaveAttribute("id");
|
||||
await expect(count).not.toHaveAttribute("role");
|
||||
await expect(page.locator('#fixture-watch [data-ryd-role="dislike"] #text')).toHaveCount(0);
|
||||
expect(backend.requestsFor("POST", "/interact/vote")).toHaveLength(0);
|
||||
expect(backend.blockedRequests).toEqual([]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
* @jest-environment-options {"url":"https://www.youtube.com/"}
|
||||
*/
|
||||
|
||||
jest.mock("../../common/vote-client", () => ({
|
||||
createVoteClient: () => ({ ensureRegistered: async () => undefined }),
|
||||
}));
|
||||
|
||||
const SHAPES = ["ytSpecButtonShapeNext", "yt-spec-button-shape-next--"];
|
||||
const originalFetch = globalThis.fetch;
|
||||
let userscript;
|
||||
let restoreDislikeButton;
|
||||
|
||||
function classesFor(prefix) {
|
||||
const older = prefix.includes("--");
|
||||
return {
|
||||
iconOnly: `${prefix}${older ? "icon-button" : "IconButton"}`,
|
||||
iconLeading: `${prefix}${older ? "icon-leading" : "IconLeading"}`,
|
||||
text: older ? "yt-spec-button-shape-next__button-text-content" : `${prefix}ButtonTextContent`,
|
||||
};
|
||||
}
|
||||
|
||||
function mountControl({ prefix = SHAPES[0], tag = "button", text = "", classes } = {}) {
|
||||
const shape = classesFor(prefix);
|
||||
document.body.innerHTML = `
|
||||
<dislike-button-view-model>
|
||||
<${tag} id="button" class="${classes ?? `${shape.iconOnly} native-unrelated-class`}" aria-label="Dislike this video" aria-pressed="false" title="Dislike" style="width:40px">
|
||||
<span data-native-icon aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M2 2h8v8H2z"></path></svg></span>
|
||||
${text}
|
||||
<span data-native-feedback aria-hidden="true"></span>
|
||||
</${tag}>
|
||||
</dislike-button-view-model>`;
|
||||
return {
|
||||
host: document.querySelector("dislike-button-view-model"),
|
||||
button: document.querySelector(tag),
|
||||
icon: document.querySelector("[data-native-icon]"),
|
||||
feedback: document.querySelector("[data-native-feedback]"),
|
||||
shape,
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
globalThis.fetch = jest.fn();
|
||||
globalThis.__RYD_LIVE_TEST_BUILD__ = false;
|
||||
userscript = require("./userscript-entry").__RewireAPI__;
|
||||
jest.clearAllTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
history.replaceState({}, "", "/watch?v=abcdefghijk");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreDislikeButton?.();
|
||||
restoreDislikeButton = undefined;
|
||||
document.body.replaceChildren();
|
||||
document.documentElement.removeAttribute("dir");
|
||||
history.replaceState({}, "", "/");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
delete globalThis.__RYD_LIVE_TEST_BUILD__;
|
||||
});
|
||||
|
||||
test.each(SHAPES)("gives an icon-only %s watch button the native text layout without rebuilding it", (prefix) => {
|
||||
const { host, button, icon, feedback, shape } = mountControl({ prefix });
|
||||
const beforeIcon = icon.outerHTML;
|
||||
const count = userscript.__get__("getDislikeTextContainer")(host);
|
||||
|
||||
expect(count.tagName).toBe("DIV");
|
||||
expect(count.id).toBe("text");
|
||||
expect(count.getAttribute("role")).toBe("text");
|
||||
expect(count.classList.contains(shape.text)).toBe(true);
|
||||
expect(count.parentElement).toBe(button);
|
||||
expect(count.style.marginLeft).toBe("");
|
||||
expect(count.style.marginInlineStart).toBe("");
|
||||
expect(button.classList.contains(shape.iconOnly)).toBe(false);
|
||||
expect(button.classList.contains(shape.iconLeading)).toBe(true);
|
||||
expect(button.classList.contains("native-unrelated-class")).toBe(true);
|
||||
expect(button.style.width).toBe("40px");
|
||||
expect(button.getAttribute("aria-label")).toBe("Dislike this video");
|
||||
expect(button.getAttribute("aria-pressed")).toBe("false");
|
||||
expect(button.title).toBe("Dislike");
|
||||
expect(host.querySelector("[data-native-icon]")).toBe(icon);
|
||||
expect(icon.outerHTML).toBe(beforeIcon);
|
||||
expect(host.querySelector("[data-native-feedback]")).toBe(feedback);
|
||||
expect(userscript.__get__("getActivationTarget")(host)).toBe(button);
|
||||
expect(userscript.__get__("getDislikeTextContainer")(host)).toBe(count);
|
||||
expect(host.querySelectorAll("#text")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test.each([
|
||||
'<span id="text" role="text" class="native-count">12</span>',
|
||||
'<yt-formatted-string class="native-count">12</yt-formatted-string>',
|
||||
'<span role="text" class="native-count">12</span>',
|
||||
'<div class="ytSpecButtonShapeNextButtonTextContent native-count">12</div>',
|
||||
'<div class="yt-spec-button-shape-next__button-text-content native-count">12</div>',
|
||||
])("reuses an existing native count and all native button layout (%s)", (text) => {
|
||||
const { host, button } = mountControl({ text });
|
||||
const existing = host.querySelector(".native-count");
|
||||
const before = host.outerHTML;
|
||||
|
||||
expect(userscript.__get__("getDislikeTextContainer")(host)).toBe(existing);
|
||||
expect(userscript.__get__("getDislikeTextContainer")(host)).toBe(existing);
|
||||
expect(host.outerHTML).toBe(before);
|
||||
expect(userscript.__get__("getActivationTarget")(host)).toBe(button);
|
||||
});
|
||||
|
||||
test("repeated count updates retain one container, its native icon, and its activation target", () => {
|
||||
const { host, button, icon } = mountControl();
|
||||
restoreDislikeButton = userscript.__set__("getDislikeButton", () => host);
|
||||
userscript.__get__("setDislikes")("25");
|
||||
const count = userscript.__get__("getDislikeTextContainer")(host);
|
||||
userscript.__get__("setDislikes")("26");
|
||||
userscript.__get__("setDislikes")("26");
|
||||
|
||||
expect(count.innerText).toBe("26");
|
||||
expect(host.querySelectorAll("#text")).toHaveLength(1);
|
||||
expect(host.querySelector("#text")).toBe(count);
|
||||
expect(host.querySelector("[data-native-icon]")).toBe(icon);
|
||||
expect(userscript.__get__("getActivationTarget")(host)).toBe(button);
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each(["ytSpecButtonShapeNextButtonTextContent", "yt-spec-button-shape-next__button-text-content"])(
|
||||
"clears a reused class-only native %s count without replacing its node or attributes",
|
||||
(textClass) => {
|
||||
const { host, button, icon } = mountControl({
|
||||
text: `<div class="${textClass}" data-native-label="dislikes">25</div>`,
|
||||
});
|
||||
const count = host.querySelector(`.${textClass}`);
|
||||
const beforeAttributes = count.getAttributeNames().map((name) => [name, count.getAttribute(name)]);
|
||||
|
||||
userscript.__get__("clearWatchPresentation")(document.body, host);
|
||||
|
||||
expect(count.textContent).toBe("");
|
||||
expect(host.querySelector(`.${textClass}`)).toBe(count);
|
||||
expect(count.getAttributeNames().map((name) => [name, count.getAttribute(name)])).toEqual(beforeAttributes);
|
||||
expect(userscript.__get__("getDislikeTextContainer")(host)).toBe(count);
|
||||
expect(host.querySelector("[data-native-icon]")).toBe(icon);
|
||||
expect(userscript.__get__("getActivationTarget")(host)).toBe(button);
|
||||
},
|
||||
);
|
||||
|
||||
test("creates a fresh count on replacement controls inside the same view model", () => {
|
||||
const { host, button } = mountControl();
|
||||
const count = userscript.__get__("getDislikeTextContainer")(host);
|
||||
const replacement = document.createElement("button");
|
||||
replacement.className = classesFor(SHAPES[0]).iconOnly;
|
||||
replacement.setAttribute("aria-pressed", "true");
|
||||
replacement.appendChild(document.createElement("svg"));
|
||||
button.replaceWith(replacement);
|
||||
const nextCount = userscript.__get__("getDislikeTextContainer")(host);
|
||||
|
||||
expect(count.isConnected).toBe(false);
|
||||
expect(nextCount).not.toBe(count);
|
||||
expect(nextCount.parentElement).toBe(replacement);
|
||||
expect(replacement.querySelectorAll("#text")).toHaveLength(1);
|
||||
expect(replacement.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(userscript.__get__("getActivationTarget")(host)).toBe(replacement);
|
||||
});
|
||||
|
||||
test.each(["button", "tp-yt-paper-button"])(
|
||||
"keeps %s legacy activation and uses direction-aware fallback spacing",
|
||||
(tag) => {
|
||||
const { host, button, icon } = mountControl({ tag, classes: "legacy-native-button" });
|
||||
document.documentElement.dir = "rtl";
|
||||
const count = userscript.__get__("getDislikeTextContainer")(host);
|
||||
|
||||
expect(count.tagName).toBe("SPAN");
|
||||
expect(count.getAttribute("role")).toBe("text");
|
||||
expect(count.style.marginInlineStart).toBe("6px");
|
||||
expect(count.style.marginLeft).toBe("");
|
||||
expect(count.parentElement).toBe(button);
|
||||
expect(button.style.width).toBe("auto");
|
||||
expect(host.querySelector("[data-native-icon]")).toBe(icon);
|
||||
expect(userscript.__get__("getActivationTarget")(host)).toBe(button);
|
||||
expect(userscript.__get__("getDislikeTextContainer")(button)).toBe(count);
|
||||
},
|
||||
);
|
||||
|
||||
test("preserves the existing fallback layout on native Shorts controls", () => {
|
||||
history.replaceState({}, "", "/shorts/abcdefghijk");
|
||||
const { host, button, shape } = mountControl();
|
||||
const count = userscript.__get__("getDislikeTextContainer")(host);
|
||||
|
||||
expect(count.tagName).toBe("SPAN");
|
||||
expect(count.style.marginLeft).toBe("6px");
|
||||
expect(count.style.marginInlineStart).toBe("");
|
||||
expect(button.style.width).toBe("auto");
|
||||
expect(button.classList.contains(shape.iconOnly)).toBe(true);
|
||||
expect(button.classList.contains(shape.iconLeading)).toBe(false);
|
||||
});
|
||||
|
||||
test("preserves a Shorts label beside its native activation button", () => {
|
||||
history.replaceState({}, "", "/shorts/abcdefghijk");
|
||||
const { host, button } = mountControl();
|
||||
const label = document.createElement("label");
|
||||
const count = document.createElement("span");
|
||||
count.id = "text";
|
||||
count.setAttribute("role", "text");
|
||||
count.textContent = "25";
|
||||
host.appendChild(label);
|
||||
label.append(button, count);
|
||||
const before = host.outerHTML;
|
||||
|
||||
expect(userscript.__get__("getDislikeTextContainer")(host)).toBe(count);
|
||||
expect(userscript.__get__("getActivationTarget")(host)).toBe(button);
|
||||
expect(host.outerHTML).toBe(before);
|
||||
});
|
||||
|
||||
test("waits when a dislike host has no native activation target", () => {
|
||||
document.body.innerHTML = '<dislike-button-view-model><span aria-hidden="true"></span></dislike-button-view-model>';
|
||||
const host = document.querySelector("dislike-button-view-model");
|
||||
const before = host.outerHTML;
|
||||
|
||||
expect(userscript.__get__("getDislikeTextContainer")(host)).toBeNull();
|
||||
expect(host.outerHTML).toBe(before);
|
||||
expect(userscript.__get__("getDislikeTextContainer")(null)).toBeNull();
|
||||
});
|
||||
@@ -839,20 +839,38 @@ function getDislikeTextContainer(dislikeButton = getDislikeButton()) {
|
||||
let result =
|
||||
dislikeButton.querySelector("#text") ??
|
||||
dislikeButton.getElementsByTagName("yt-formatted-string")[0] ??
|
||||
dislikeButton.querySelector("span[role='text']");
|
||||
dislikeButton.querySelector("span[role='text']") ??
|
||||
dislikeButton.querySelector(
|
||||
".ytSpecButtonShapeNextButtonTextContent, .yt-spec-button-shape-next__button-text-content",
|
||||
);
|
||||
if (result == null) {
|
||||
const activationTarget = getActivationTarget(dislikeButton);
|
||||
if (!activationTarget?.matches("button, tp-yt-paper-button#button")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const textSpan = document.createElement("span");
|
||||
textSpan.id = "text";
|
||||
textSpan.setAttribute("role", "text");
|
||||
textSpan.style.marginLeft = "6px";
|
||||
activationTarget.appendChild(textSpan);
|
||||
activationTarget.style.width = "auto";
|
||||
result = textSpan;
|
||||
const shorts = isShorts();
|
||||
const usesNativeShape =
|
||||
!shorts &&
|
||||
activationTarget.matches(
|
||||
".ytSpecButtonShapeNextHost, .yt-spec-button-shape-next, " +
|
||||
".ytSpecButtonShapeNextIconButton, .yt-spec-button-shape-next--icon-button, " +
|
||||
".ytSpecButtonShapeNextIconLeading, .yt-spec-button-shape-next--icon-leading",
|
||||
);
|
||||
const text = document.createElement(usesNativeShape ? "div" : "span");
|
||||
text.id = "text";
|
||||
text.setAttribute("role", "text");
|
||||
if (usesNativeShape) {
|
||||
text.className = "ytSpecButtonShapeNextButtonTextContent yt-spec-button-shape-next__button-text-content";
|
||||
activationTarget.classList.remove("ytSpecButtonShapeNextIconButton", "yt-spec-button-shape-next--icon-button");
|
||||
activationTarget.classList.add("ytSpecButtonShapeNextIconLeading", "yt-spec-button-shape-next--icon-leading");
|
||||
} else {
|
||||
if (shorts) text.style.marginLeft = "6px";
|
||||
else text.style.marginInlineStart = "6px";
|
||||
activationTarget.style.width = "auto";
|
||||
}
|
||||
activationTarget.appendChild(text);
|
||||
result = text;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1378,7 +1396,10 @@ function clearWatchPresentation(buttons, dislikeButton) {
|
||||
const dislikeText =
|
||||
dislikeButton?.querySelector("#text") ??
|
||||
dislikeButton?.getElementsByTagName("yt-formatted-string")[0] ??
|
||||
dislikeButton?.querySelector("span[role='text']");
|
||||
dislikeButton?.querySelector("span[role='text']") ??
|
||||
dislikeButton?.querySelector(
|
||||
".ytSpecButtonShapeNextButtonTextContent, .yt-spec-button-shape-next__button-text-content",
|
||||
);
|
||||
if (dislikeText) {
|
||||
dislikeText.textContent = "";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user