diff --git a/AMO_RELEASE_NOTES.md b/AMO_RELEASE_NOTES.md
index 8e40c31..3ddc77c 100644
--- a/AMO_RELEASE_NOTES.md
+++ b/AMO_RELEASE_NOTES.md
@@ -1,7 +1,9 @@
-# Return YouTube Dislike 4.0.6
+# Return YouTube Dislike 4.1.0
What's new since 3.0.0.18:
+- **RYD comments:** read and react to discussions under videos and Shorts. Commenter and Premium members can post and reply, with notifications for replies and reactions.
+- **Firefox consent and translations:** updated permission controls and new translations.
- **YouTube Shorts support:** see dislike counts and vote on Shorts, with fixes for switching between videos and keeping the buttons in sync.
- **More reliable counts and voting:** fixes for the latest YouTube layouts, stale counts after navigation, and votes that failed to submit.
- **Premium statistics:** explore likes and dislikes over time, compare activity by country, and browse interactive world and US maps.
diff --git a/Extensions/UserScript/e2e/fixtures/navigation-page.html b/Extensions/UserScript/e2e/fixtures/navigation-page.html
index 8d9bef7..97011fd 100644
--- a/Extensions/UserScript/e2e/fixtures/navigation-page.html
+++ b/Extensions/UserScript/e2e/fixtures/navigation-page.html
@@ -755,7 +755,7 @@
const button = control?.querySelector("button");
if (control && button) {
control.setAttribute("data-fixture-native-hydration-pending", role);
- delayedNativeButtons.push({ button, control });
+ delayedNativeButtons.push({ button, control, parent: button.parentElement });
button.remove();
}
}
@@ -772,9 +772,15 @@
}
if (delayedNativeButtons.length > 0) {
setTimeout(() => {
- for (const { button, control } of delayedNativeButtons) {
- if (replacement.isConnected && control.isConnected && !control.querySelector("button")) {
- control.appendChild(button);
+ for (const { button, control, parent } of delayedNativeButtons) {
+ if (
+ replacement.isConnected &&
+ control.isConnected &&
+ parent.isConnected &&
+ control.contains(parent) &&
+ !control.querySelector("button")
+ ) {
+ parent.appendChild(button);
control.removeAttribute("data-fixture-native-hydration-pending");
}
}
diff --git a/Extensions/combined/consent.js b/Extensions/combined/consent.js
index 0021c64..fb5b864 100644
--- a/Extensions/combined/consent.js
+++ b/Extensions/combined/consent.js
@@ -1,11 +1,24 @@
import { consentApi, hasCoreDataPermission, requestCoreDataPermission } from "./src/core-data-consent";
+import { isPrivateBrowsingContext } from "./src/private-browsing";
const continueButton = document.getElementById("consent-continue");
const declineButton = document.getElementById("consent-decline");
const status = document.getElementById("consent-status");
-const canRequestPermissions = typeof consentApi()?.permissions?.request === "function";
+const privateBrowsing = isPrivateBrowsingContext();
+const privateSetupInstruction = "Enable RYD in a normal Firefox window to load counts here.";
+const canRequestPermissions = !privateBrowsing && typeof consentApi()?.permissions?.request === "function";
-if (!canRequestPermissions) {
+if (privateBrowsing) {
+ const main = document.querySelector("main");
+ const message = document.createElement("p");
+ message.textContent =
+ "Counts only in private windows. Voting, sign-in, premium analytics and discussions are unavailable here.";
+ const instruction = document.getElementById("consent-next-step");
+ instruction.textContent = privateSetupInstruction;
+ const actions = document.getElementById("consent-actions");
+ actions.replaceChildren(declineButton);
+ main.replaceChildren(main.querySelector("img"), main.querySelector("h1"), message, instruction, actions, status);
+} else if (!canRequestPermissions) {
document.getElementById("consent-next-step").textContent =
"Continue opens RYD's permission page. Firefox will only ask after you select Allow in Firefox there. " +
"If you decline, RYD will not load counts or submit votes. YouTube's own buttons will still work.";
@@ -27,50 +40,56 @@ function notifyParent(type) {
}
async function dismiss() {
- try {
- await consentApi().storage.local.set({ rydConsentDismissed: true });
- } catch (_) {}
- status.textContent = "RYD is paused. You can enable it here whenever you're ready.";
+ if (!privateBrowsing) {
+ try {
+ await consentApi().storage.local.set({ rydConsentDismissed: true });
+ } catch (_) {}
+ }
+ status.textContent = privateBrowsing
+ ? privateSetupInstruction
+ : "RYD is paused. You can enable it here whenever you're ready.";
notifyParent("ryd-consent-dismissed");
}
-continueButton.addEventListener("click", () => {
- if (!canRequestPermissions) {
- continueButton.disabled = true;
- void consentApi()
- .runtime.sendMessage({ message: "ryd_open_consent" })
- .then(
- (response) => {
- continueButton.disabled = false;
- status.textContent = response?.success
- ? "Finish enabling RYD in the permission page."
- : "Open the RYD extension menu to review permissions.";
- },
- () => {
- continueButton.disabled = false;
- status.textContent = "Open the RYD extension menu to review permissions.";
- },
- );
- return;
- }
- notifyParent("ryd-consent-requested");
- const request = requestCoreDataPermission();
- continueButton.disabled = true;
- declineButton.disabled = true;
- status.textContent = "Choose whether to allow the permissions in Firefox.";
- void request.then(async (granted) => {
- if (granted && (await hasCoreDataPermission())) {
- try {
- await consentApi().storage.local.remove("rydConsentDismissed");
- } catch (_) {}
- document.getElementById("consent-actions").hidden = true;
- status.textContent = "RYD is enabled. You can return to YouTube.";
- notifyParent("ryd-consent-granted");
- } else {
- await dismiss();
- continueButton.disabled = false;
- declineButton.disabled = false;
+if (!privateBrowsing) {
+ continueButton.addEventListener("click", () => {
+ if (!canRequestPermissions) {
+ continueButton.disabled = true;
+ void consentApi()
+ .runtime.sendMessage({ message: "ryd_open_consent" })
+ .then(
+ (response) => {
+ continueButton.disabled = false;
+ status.textContent = response?.success
+ ? "Finish enabling RYD in the permission page."
+ : "Open the RYD extension menu to review permissions.";
+ },
+ () => {
+ continueButton.disabled = false;
+ status.textContent = "Open the RYD extension menu to review permissions.";
+ },
+ );
+ return;
}
+ notifyParent("ryd-consent-requested");
+ const request = requestCoreDataPermission();
+ continueButton.disabled = true;
+ declineButton.disabled = true;
+ status.textContent = "Choose whether to allow the permissions in Firefox.";
+ void request.then(async (granted) => {
+ if (granted && (await hasCoreDataPermission())) {
+ try {
+ await consentApi().storage.local.remove("rydConsentDismissed");
+ } catch (_) {}
+ document.getElementById("consent-actions").hidden = true;
+ status.textContent = "RYD is enabled. You can return to YouTube.";
+ notifyParent("ryd-consent-granted");
+ } else {
+ await dismiss();
+ continueButton.disabled = false;
+ declineButton.disabled = false;
+ }
+ });
});
-});
+}
declineButton.addEventListener("click", dismiss);
diff --git a/Extensions/combined/consent.spec.js b/Extensions/combined/consent.spec.js
index 39ca892..ed56c4a 100644
--- a/Extensions/combined/consent.spec.js
+++ b/Extensions/combined/consent.spec.js
@@ -6,21 +6,32 @@ jest.mock("./src/core-data-consent", () => ({
requestCoreDataPermission: jest.fn(),
consentApi: jest.fn(),
}));
+jest.mock("./src/private-browsing", () => ({ isPrivateBrowsingContext: jest.fn() }));
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
describe("Firefox consent explainer", () => {
let permission;
let local;
- beforeEach(() => {
+ let sendMessage;
+ function openExplainer({ privateBrowsing = false, canRequest = true } = {}) {
jest.resetModules();
document.documentElement.innerHTML = fs.readFileSync(path.join(__dirname, "consent.html"), "utf8");
permission = require("./src/core-data-consent");
- local = { set: jest.fn(async () => {}), remove: jest.fn(async () => {}) };
- permission.consentApi.mockReturnValue({ storage: { local }, permissions: { request: jest.fn() } });
+ require("./src/private-browsing").isPrivateBrowsingContext.mockReturnValue(privateBrowsing);
+ local = { get: jest.fn(), set: jest.fn(async () => {}), remove: jest.fn(async () => {}) };
+ sendMessage = jest.fn(async () => ({ success: true }));
+ permission.consentApi.mockReturnValue({
+ storage: { local },
+ runtime: { sendMessage },
+ ...(canRequest ? { permissions: { request: jest.fn() } } : {}),
+ });
permission.hasCoreDataPermission.mockResolvedValue(false);
permission.requestCoreDataPermission.mockResolvedValue(false);
+ const continueButton = document.getElementById("consent-continue");
require("./consent");
- });
+ return continueButton;
+ }
+ beforeEach(() => openExplainer());
test("opening the explainer and selecting Not now request no permissions", async () => {
expect(document.querySelector("main").textContent).toMatch(/random ID/);
@@ -50,16 +61,40 @@ describe("Firefox consent explainer", () => {
});
test("a page-embedded explainer opens the extension consent page without requesting permissions", async () => {
- jest.resetModules();
- document.documentElement.innerHTML = fs.readFileSync(path.join(__dirname, "consent.html"), "utf8");
- permission = require("./src/core-data-consent");
- const sendMessage = jest.fn(async () => ({ success: true }));
- permission.consentApi.mockReturnValue({ storage: { local }, runtime: { sendMessage } });
- require("./consent");
+ openExplainer({ canRequest: false });
document.getElementById("consent-continue").click();
await flush();
expect(sendMessage).toHaveBeenCalledWith({ message: "ryd_open_consent" });
expect(permission.requestCoreDataPermission).not.toHaveBeenCalled();
expect(document.getElementById("consent-next-step").textContent).toMatch(/permission page/);
});
+
+ test.each([true, false])(
+ "private setup stays local without requesting permissions or opening a normal page when request API availability is %s",
+ async (canRequest) => {
+ const continueButton = openExplainer({ privateBrowsing: true, canRequest });
+ expect(document.getElementById("consent-next-step").textContent).toBe(
+ "Enable RYD in a normal Firefox window to load counts here.",
+ );
+ expect(document.querySelector("main").textContent).toContain(
+ "Counts only in private windows. Voting, sign-in, premium analytics and discussions are unavailable here.",
+ );
+ expect(document.querySelector("main").textContent).not.toMatch(/random ID|Allow in Firefox|Continue/);
+ expect(document.getElementById("consent-continue")).toBeNull();
+ continueButton.click();
+ document.getElementById("consent-decline").click();
+ await flush();
+
+ expect(permission.consentApi).not.toHaveBeenCalled();
+ expect(permission.hasCoreDataPermission).not.toHaveBeenCalled();
+ expect(permission.requestCoreDataPermission).not.toHaveBeenCalled();
+ expect(local.get).not.toHaveBeenCalled();
+ expect(local.set).not.toHaveBeenCalled();
+ expect(local.remove).not.toHaveBeenCalled();
+ expect(sendMessage).not.toHaveBeenCalled();
+ expect(document.getElementById("consent-status").textContent).toBe(
+ "Enable RYD in a normal Firefox window to load counts here.",
+ );
+ },
+ );
});
diff --git a/Extensions/combined/content-style.css b/Extensions/combined/content-style.css
index fdb57ec..8db4b48 100644
--- a/Extensions/combined/content-style.css
+++ b/Extensions/combined/content-style.css
@@ -903,13 +903,33 @@ ytd-menu-renderer.ytd-watch-metadata {
color: var(--yt-spec-call-to-action, #3ea6ff);
}
+.ryd-premium-analytics,
+.ryd-premium-tier-notice {
+ --ryd-analytics-bg: var(--yt-spec-base-background, #fff);
+ --ryd-analytics-fg: var(--yt-spec-text-primary, #0f0f0f);
+ --ryd-analytics-muted: var(--yt-spec-text-secondary, #606060);
+ --ryd-analytics-border: rgba(0, 0, 0, 0.08);
+ --ryd-analytics-surface: rgba(15, 23, 42, 0.04);
+ --ryd-analytics-row: rgba(15, 23, 42, 0.03);
+}
+
+html[dark]:not([dark="false"]):not([light]) .ryd-premium-analytics,
+html[dark]:not([dark="false"]):not([light]) .ryd-premium-tier-notice {
+ --ryd-analytics-bg: var(--yt-spec-base-background, #202020);
+ --ryd-analytics-fg: var(--yt-spec-text-primary, #fff);
+ --ryd-analytics-muted: var(--yt-spec-text-secondary, #bbb);
+ --ryd-analytics-border: rgba(255, 255, 255, 0.08);
+ --ryd-analytics-surface: rgba(255, 255, 255, 0.02);
+ --ryd-analytics-row: rgba(255, 255, 255, 0.03);
+}
+
.ryd-premium-analytics {
- background: var(--yt-spec-base-background, #202020);
- border: 1px solid rgba(255, 255, 255, 0.08);
+ background: var(--ryd-analytics-bg);
+ border: 1px solid var(--ryd-analytics-border);
border-radius: 16px;
padding: 16px;
margin-bottom: 16px;
- color: var(--yt-spec-text-primary, #ffffff);
+ color: var(--ryd-analytics-fg);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.22);
font-family: Roboto, Arial, sans-serif;
position: relative;
@@ -996,12 +1016,12 @@ html[dark] .ryd-premium-teaser {
}
.ryd-premium-tier-notice {
- background: var(--yt-spec-base-background, #202020);
- border: 1px solid rgba(255, 255, 255, 0.08);
+ background: var(--ryd-analytics-bg);
+ border: 1px solid var(--ryd-analytics-border);
border-radius: 16px;
padding: 20px;
margin-bottom: 16px;
- color: var(--yt-spec-text-primary, #ffffff);
+ color: var(--ryd-analytics-fg);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.18);
font-family: Roboto, Arial, sans-serif;
display: flex;
@@ -1026,7 +1046,7 @@ html[dark] .ryd-premium-teaser {
.ryd-premium-tier-notice__message {
margin: 0;
font-size: 13px;
- color: var(--yt-spec-text-secondary, #bcccdc);
+ color: var(--ryd-analytics-muted);
line-height: 1.5;
}
@@ -1282,6 +1302,7 @@ html[dark] .ryd-premium-teaser__toggle:focus-visible {
.ryd-analytics__controls {
display: flex;
+ flex-wrap: wrap;
gap: 12px;
align-items: center;
flex: 1 1 auto;
@@ -1329,30 +1350,30 @@ html[dark] .ryd-premium-teaser__toggle:focus-visible {
color: #0f172a;
}
-html[dark] .ryd-analytics__ranges,
-html[dark] .ryd-analytics__modes,
-html[dark] .ryd-analytics__window-toggle {
+html[dark]:not([dark="false"]):not([light]) .ryd-analytics__ranges,
+html[dark]:not([dark="false"]):not([light]) .ryd-analytics__modes,
+html[dark]:not([dark="false"]):not([light]) .ryd-analytics__window-toggle {
background: rgba(255, 255, 255, 0.05);
border: none;
}
-html[dark] .ryd-analytics__ranges button,
-html[dark] .ryd-analytics__modes button,
-html[dark] .ryd-analytics__window-toggle button {
+html[dark]:not([dark="false"]):not([light]) .ryd-analytics__ranges button,
+html[dark]:not([dark="false"]):not([light]) .ryd-analytics__modes button,
+html[dark]:not([dark="false"]):not([light]) .ryd-analytics__window-toggle button {
color: var(--yt-spec-text-secondary, #bbbbbb);
}
-html[dark] .ryd-analytics__ranges button.is-active,
-html[dark] .ryd-analytics__modes button.is-active,
-html[dark] .ryd-analytics__window-toggle button.is-active {
+html[dark]:not([dark="false"]):not([light]) .ryd-analytics__ranges button.is-active,
+html[dark]:not([dark="false"]):not([light]) .ryd-analytics__modes button.is-active,
+html[dark]:not([dark="false"]):not([light]) .ryd-analytics__window-toggle button.is-active {
background: rgba(255, 255, 255, 0.15);
color: var(--yt-spec-text-primary, #ffffff);
box-shadow: none;
}
-html[dark] .ryd-analytics__ranges button:hover,
-html[dark] .ryd-analytics__modes button:hover,
-html[dark] .ryd-analytics__window-toggle button:hover {
+html[dark]:not([dark="false"]):not([light]) .ryd-analytics__ranges button:hover,
+html[dark]:not([dark="false"]):not([light]) .ryd-analytics__modes button:hover,
+html[dark]:not([dark="false"]):not([light]) .ryd-analytics__window-toggle button:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--yt-spec-text-primary, #ffffff);
}
@@ -1375,11 +1396,11 @@ html[dark] .ryd-analytics__window-toggle button:hover {
color: #4b5563;
}
-html[dark] .ryd-analytics__window-label {
+html[dark]:not([dark="false"]):not([light]) .ryd-analytics__window-label {
color: var(--yt-spec-text-primary, #ffffff);
}
-html[dark] .ryd-analytics__window-hint {
+html[dark]:not([dark="false"]):not([light]) .ryd-analytics__window-hint {
color: var(--yt-spec-text-secondary, #a3a3a3);
}
@@ -1390,7 +1411,7 @@ html[dark] .ryd-analytics__window-hint {
}
.ryd-analytics__section {
- background: rgba(255, 255, 255, 0.02);
+ background: var(--ryd-analytics-surface);
border-radius: 16px;
padding: 12px;
display: flex;
@@ -1439,14 +1460,14 @@ html[dark] .ryd-analytics__window-hint {
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.4);
}
-html[dark] .ryd-analytics__section-expand {
+html[dark]:not([dark="false"]):not([light]) .ryd-analytics__section-expand {
border: none;
background: rgba(255, 255, 255, 0.08);
color: var(--yt-spec-text-secondary, #cbd5f5);
}
-html[dark] .ryd-analytics__section-expand.is-active,
-html[dark] .ryd-analytics__section-expand:hover {
+html[dark]:not([dark="false"]):not([light]) .ryd-analytics__section-expand.is-active,
+html[dark]:not([dark="false"]):not([light]) .ryd-analytics__section-expand:hover {
background: rgba(99, 102, 241, 0.25);
color: #f8fafc;
box-shadow: none;
@@ -1471,7 +1492,7 @@ html[dark] .ryd-analytics__section-expand:hover {
.ryd-analytics__bucket-label {
font-size: 12px;
- color: var(--yt-spec-text-secondary, #bbbbbb);
+ color: var(--ryd-analytics-muted);
}
.ryd-analytics__chart {
@@ -1494,7 +1515,7 @@ html[dark] .ryd-analytics__section-expand:hover {
.ryd-analytics__map-label {
font-size: 12px;
- color: var(--yt-spec-text-secondary, #bbbbbb);
+ color: var(--ryd-analytics-muted);
}
.ryd-analytics__map-reset {
@@ -1517,12 +1538,12 @@ html[dark] .ryd-analytics__section-expand:hover {
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.35);
}
-html[dark] .ryd-analytics__map-reset {
+html[dark]:not([dark="false"]):not([light]) .ryd-analytics__map-reset {
background: rgba(255, 255, 255, 0.08);
color: var(--yt-spec-text-secondary, #bbbbbb);
}
-html[dark] .ryd-analytics__map-reset:hover {
+html[dark]:not([dark="false"]):not([light]) .ryd-analytics__map-reset:hover {
background: rgba(255, 255, 255, 0.18);
color: var(--yt-spec-text-primary, #ffffff);
}
@@ -1544,7 +1565,7 @@ html[dark] .ryd-analytics__map-reset:hover {
margin: 0 0 6px;
font-size: 13px;
font-weight: 600;
- color: var(--yt-spec-text-secondary, #bbbbbb);
+ color: var(--ryd-analytics-muted);
}
.ryd-analytics__list-items {
@@ -1561,8 +1582,8 @@ html[dark] .ryd-analytics__map-reset:hover {
justify-content: space-between;
align-items: center;
font-size: 12px;
- color: var(--yt-spec-text-primary, #ffffff);
- background: rgba(255, 255, 255, 0.03);
+ color: var(--ryd-analytics-fg);
+ background: var(--ryd-analytics-row);
border-radius: 6px;
padding: 6px 8px;
}
@@ -1580,7 +1601,7 @@ html[dark] .ryd-analytics__map-reset:hover {
}
.ryd-analytics__placeholder {
- color: var(--yt-spec-text-secondary, #bbbbbb);
+ color: var(--ryd-analytics-muted);
font-style: italic;
background: transparent !important;
justify-content: flex-start !important;
@@ -1588,7 +1609,7 @@ html[dark] .ryd-analytics__map-reset:hover {
.ryd-analytics__footer {
font-size: 12px;
- color: var(--yt-spec-text-secondary, #bbbbbb);
+ color: var(--ryd-analytics-muted);
}
.ryd-analytics__totals {
@@ -1617,21 +1638,21 @@ html[dark] .ryd-analytics__map-reset:hover {
letter-spacing: 0.08em;
font-weight: 600;
font-size: 10px;
- color: var(--yt-spec-text-secondary, #bbbbbb);
+ color: var(--ryd-analytics-muted);
}
.ryd-analytics__totals-value {
font-weight: 600;
font-variant-numeric: tabular-nums;
- color: var(--yt-spec-text-primary, #f1f1f1);
+ color: var(--ryd-analytics-fg);
}
.ryd-analytics__period-label {
font-size: 11px;
- color: var(--yt-spec-text-secondary, #bbbbbb);
+ color: var(--ryd-analytics-muted);
}
.ryd-analytics__summary-meta {
- color: var(--yt-spec-text-secondary, #bbbbbb);
+ color: var(--ryd-analytics-muted);
line-height: 1.4;
}
diff --git a/Extensions/combined/manifest-firefox.json b/Extensions/combined/manifest-firefox.json
index 492b248..19081a0 100644
--- a/Extensions/combined/manifest-firefox.json
+++ b/Extensions/combined/manifest-firefox.json
@@ -4,7 +4,7 @@
"default_locale": "en",
"version": "__RYD_VERSION__",
"manifest_version": 2,
- "incognito": "not_allowed",
+ "incognito": "spanning",
"background": {
"scripts": ["ryd.background.js"]
},
diff --git a/Extensions/combined/popup-context.js b/Extensions/combined/popup-context.js
new file mode 100644
index 0000000..90cb04b
--- /dev/null
+++ b/Extensions/combined/popup-context.js
@@ -0,0 +1,8 @@
+(() => {
+ if (location.protocol !== "moz-extension:") return;
+ // Prepare a distinct document URL before the first extension API access.
+ // Firefox uses that initial URL when identifying popup message senders.
+ const url = new URL(location.href);
+ url.searchParams.set("rydContext", crypto.randomUUID());
+ history.replaceState(null, "", url.href);
+})();
diff --git a/Extensions/combined/popup-context.spec.js b/Extensions/combined/popup-context.spec.js
new file mode 100644
index 0000000..78a1b36
--- /dev/null
+++ b/Extensions/combined/popup-context.spec.js
@@ -0,0 +1,35 @@
+const fs = require("node:fs");
+const path = require("node:path");
+const vm = require("node:vm");
+
+test("Firefox popup establishes its document identity before touching extension APIs", () => {
+ const source = fs.readFileSync(path.join(__dirname, "popup-context.js"), "utf8");
+ const context = {
+ location: { protocol: "moz-extension:", href: "moz-extension://fixture/popup.html?existing=1#details" },
+ URL,
+ crypto: { randomUUID: () => "synthetic-context" },
+ history: { replaceState: jest.fn() },
+ };
+ for (const api of ["browser", "chrome"]) {
+ Object.defineProperty(context, api, {
+ get() {
+ throw new Error("Extension context initialized too soon");
+ },
+ });
+ }
+ vm.runInNewContext(source, context);
+ expect(context.history.replaceState).toHaveBeenCalledWith(
+ null,
+ "",
+ "moz-extension://fixture/popup.html?existing=1&rydContext=synthetic-context#details",
+ );
+ const html = fs.readFileSync(path.join(__dirname, "popup.html"), "utf8");
+ expect(html.match(/
__MSG_extensionName__
diff --git a/Extensions/combined/popup.js b/Extensions/combined/popup.js
index ac62e7f..77cfd26 100644
--- a/Extensions/combined/popup.js
+++ b/Extensions/combined/popup.js
@@ -2,7 +2,9 @@ import { config as appConfig, getApiUrl, getApiEndpoint } from "./src/config.js"
import { getGitHubLoginErrorMessage } from "./src/github-login-error";
import { initDiscussionPopup } from "./src/discussion/popup";
import { waitForCoreDataConsent } from "./src/consent-prompt";
-import { fetchWithCoreConsent, onCoreDataPermissionChanged } from "./src/core-data-consent";
+import { onCoreDataPermissionChanged } from "./src/core-data-consent";
+import { fetchWithAccountConsent } from "./src/account-data-consent";
+import { isPrivateBrowsingContext } from "./src/private-browsing";
import { DEFAULT_CUSTOM_COLORS, getThemeColor, normalizeCustomColors } from "../common/color-themes";
import {
hasAuthenticationDataPermission,
@@ -38,6 +40,8 @@ let customColorsReady = false;
/* Change language */
function localizeHtmlPage() {
+ const direction = chrome.i18n.getMessage("@@bidi_dir");
+ if (direction === "rtl" || direction === "ltr") document.documentElement.dir = direction;
//Localize by replacing __MSG_***__ meta tags
var objects = document.getElementsByTagName("html");
for (var j = 0; j < objects.length; j++) {
@@ -55,20 +59,35 @@ function localizeHtmlPage() {
}
localizeHtmlPage();
-const coreConsentReady = waitForCoreDataConsent({ alwaysExplain: true });
-coreConsentReady.then(() => initDiscussionPopup());
-onCoreDataPermissionChanged((granted) => {
- if (!granted) void waitForCoreDataConsent({ alwaysExplain: true });
-});
+const privateBrowsing = isPrivateBrowsingContext();
+const coreConsentReady = privateBrowsing ? null : waitForCoreDataConsent({ alwaysExplain: true });
+if (privateBrowsing) {
+ const home = document.createElement("center");
+ const message = document.createElement("p");
+ message.id = "ryd-private-counts-message";
+ message.textContent =
+ "Counts only in private windows. Voting, sign-in, premium analytics and discussions are unavailable here.";
+ home.append(
+ document.querySelector("body > center > img").cloneNode(true),
+ document.querySelector("body > center > h1").cloneNode(true),
+ message,
+ );
+ document.body.replaceChildren(home);
+} else {
+ coreConsentReady.then(() => initDiscussionPopup());
+ onCoreDataPermissionChanged((granted) => {
+ if (!granted) void waitForCoreDataConsent({ alwaysExplain: true });
+ });
-/* Links */
-createLink(config.links.website, "link_website");
-createLink(config.links.github, "link_github");
-createLink(config.links.discord, "link_discord");
-createLink(config.links.faq, "link_faq");
-createLink(config.links.donate, "link_donate");
-createLink(config.links.help, "link_help");
-createLink(config.links.changelog, "link_changelog");
+ /* Links */
+ createLink(config.links.website, "link_website");
+ createLink(config.links.github, "link_github");
+ createLink(config.links.discord, "link_discord");
+ createLink(config.links.faq, "link_faq");
+ createLink(config.links.donate, "link_donate");
+ createLink(config.links.help, "link_help");
+ createLink(config.links.changelog, "link_changelog");
+}
function createLink(url, id) {
document.getElementById(id).addEventListener("click", () => {
@@ -76,73 +95,75 @@ function createLink(url, id) {
});
}
-document.getElementById("disable_vote_submission").addEventListener("click", (ev) => {
- chrome.storage.sync.set({ disableVoteSubmission: ev.target.checked });
-});
+if (!privateBrowsing) {
+ document.getElementById("disable_vote_submission").addEventListener("click", (ev) => {
+ chrome.storage.sync.set({ disableVoteSubmission: ev.target.checked });
+ });
-document.getElementById("disable_logging").addEventListener("click", (ev) => {
- chrome.storage.sync.set({ disableLogging: ev.target.checked });
-});
+ document.getElementById("disable_logging").addEventListener("click", (ev) => {
+ chrome.storage.sync.set({ disableLogging: ev.target.checked });
+ });
-document.getElementById("colored_thumbs").addEventListener("click", (ev) => {
- chrome.storage.sync.set({ coloredThumbs: ev.target.checked });
-});
+ document.getElementById("colored_thumbs").addEventListener("click", (ev) => {
+ chrome.storage.sync.set({ coloredThumbs: ev.target.checked });
+ });
-document.getElementById("colored_bar").addEventListener("click", (ev) => {
- chrome.storage.sync.set({ coloredBar: ev.target.checked });
-});
+ document.getElementById("colored_bar").addEventListener("click", (ev) => {
+ chrome.storage.sync.set({ coloredBar: ev.target.checked });
+ });
-document.getElementById("color_theme").addEventListener("change", (ev) => {
- colorThemeRevision++;
- handleColorThemeChangeEvent(ev.target.value);
- chrome.storage.sync.set({ colorTheme: ev.target.value });
-});
+ document.getElementById("color_theme").addEventListener("change", (ev) => {
+ colorThemeRevision++;
+ handleColorThemeChangeEvent(ev.target.value);
+ chrome.storage.sync.set({ colorTheme: ev.target.value });
+ });
-for (const mode of ["light", "dark"]) {
- for (const vote of ["like", "dislike"]) {
- const input = document.getElementById(`custom_color_${mode}_${vote}`);
- input.addEventListener("input", () => {
- if (!customColorsReady) return;
- customColorsRevision++;
- const colors = normalizeCustomColors(config.customColors);
- colors[mode][vote] = input.value;
- handleCustomColorsChangeEvent(colors);
- });
- input.addEventListener("change", () => {
- if (!customColorsReady) return;
- const colors = normalizeCustomColors(config.customColors);
- colors[mode][vote] = input.value;
- customColorsRevision++;
- handleCustomColorsChangeEvent(colors);
- chrome.storage.sync.set({ customColors: colors });
- });
+ for (const mode of ["light", "dark"]) {
+ for (const vote of ["like", "dislike"]) {
+ const input = document.getElementById(`custom_color_${mode}_${vote}`);
+ input.addEventListener("input", () => {
+ if (!customColorsReady) return;
+ customColorsRevision++;
+ const colors = normalizeCustomColors(config.customColors);
+ colors[mode][vote] = input.value;
+ handleCustomColorsChangeEvent(colors);
+ });
+ input.addEventListener("change", () => {
+ if (!customColorsReady) return;
+ const colors = normalizeCustomColors(config.customColors);
+ colors[mode][vote] = input.value;
+ customColorsRevision++;
+ handleCustomColorsChangeEvent(colors);
+ chrome.storage.sync.set({ customColors: colors });
+ });
+ }
}
+
+ document.getElementById("number_format").addEventListener("change", (ev) => {
+ chrome.storage.sync.set({ numberDisplayFormat: ev.target.value });
+ });
+
+ document.getElementById("show_tooltip_percentage").addEventListener("click", (ev) => {
+ chrome.storage.sync.set({ showTooltipPercentage: ev.target.checked });
+ });
+
+ document.getElementById("tooltip_percentage_mode").addEventListener("change", (ev) => {
+ chrome.storage.sync.set({ tooltipPercentageMode: ev.target.value });
+ });
+
+ document.getElementById("number_reformat_likes").addEventListener("click", (ev) => {
+ chrome.storage.sync.set({ numberDisplayReformatLikes: ev.target.checked });
+ });
+
+ document.getElementById("hide_premium_teaser").addEventListener("click", (ev) => {
+ chrome.storage.sync.set({ hidePremiumTeaser: ev.target.checked });
+ });
+
+ document.getElementById("hide_clutter_buttons").addEventListener("click", (ev) => {
+ chrome.storage.sync.set({ hideClutterButtons: ev.target.checked });
+ });
}
-document.getElementById("number_format").addEventListener("change", (ev) => {
- chrome.storage.sync.set({ numberDisplayFormat: ev.target.value });
-});
-
-document.getElementById("show_tooltip_percentage").addEventListener("click", (ev) => {
- chrome.storage.sync.set({ showTooltipPercentage: ev.target.checked });
-});
-
-document.getElementById("tooltip_percentage_mode").addEventListener("change", (ev) => {
- chrome.storage.sync.set({ tooltipPercentageMode: ev.target.value });
-});
-
-document.getElementById("number_reformat_likes").addEventListener("click", (ev) => {
- chrome.storage.sync.set({ numberDisplayReformatLikes: ev.target.checked });
-});
-
-document.getElementById("hide_premium_teaser").addEventListener("click", (ev) => {
- chrome.storage.sync.set({ hidePremiumTeaser: ev.target.checked });
-});
-
-document.getElementById("hide_clutter_buttons").addEventListener("click", (ev) => {
- chrome.storage.sync.set({ hideClutterButtons: ev.target.checked });
-});
-
function initPatreonAuth() {
const loggedOutView = document.getElementById("patreon-logged-out");
const loggedInView = document.getElementById("patreon-logged-in");
@@ -252,7 +273,7 @@ function initPatreonAuth() {
return { status: "consent_required" };
}
- const response = await fetchWithCoreConsent(getApiEndpoint("/api/auth/verify"), {
+ const response = await fetchWithAccountConsent(getApiEndpoint("/api/auth/verify"), {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -479,6 +500,13 @@ function initPatreonAuth() {
showLoggedOutView();
});
+ onCoreDataPermissionChanged((granted) => {
+ if (granted) return;
+ authenticationGeneration++;
+ chrome.storage.sync.remove(["patreonAuthenticated", "patreonUser", "patreonSessionToken"]);
+ showLoggedOutView();
+ });
+
logoutBtn.addEventListener("click", () => {
authenticationGeneration++;
showLoggedOutView();
@@ -487,28 +515,30 @@ function initPatreonAuth() {
});
}
-coreConsentReady.then(() => initPatreonAuth());
+if (!privateBrowsing) {
+ coreConsentReady.then(() => initPatreonAuth());
-/* Advanced Toggle */
-const advancedToggle = document.getElementById("advancedToggle");
-advancedToggle.addEventListener("click", () => {
- const adv = document.getElementById("advancedSettings");
- if (config.advanced) {
- adv.style.transform = "scale(1.1)";
- adv.style.pointerEvents = "none";
- adv.style.opacity = "0";
- advancedToggle.innerHTML = config.showAdvancedMessage;
- } else {
- adv.style.transform = "scale(1)";
- adv.style.pointerEvents = "auto";
- adv.style.opacity = "1";
- advancedToggle.innerHTML = config.hideAdvancedMessage;
- }
- config.advanced = !config.advanced;
-});
+ /* Advanced Toggle */
+ const advancedToggle = document.getElementById("advancedToggle");
+ advancedToggle.addEventListener("click", () => {
+ const adv = document.getElementById("advancedSettings");
+ if (config.advanced) {
+ adv.style.transform = "scale(1.1)";
+ adv.style.pointerEvents = "none";
+ adv.style.opacity = "0";
+ advancedToggle.innerHTML = config.showAdvancedMessage;
+ } else {
+ adv.style.transform = "scale(1)";
+ adv.style.pointerEvents = "auto";
+ adv.style.opacity = "1";
+ advancedToggle.innerHTML = config.hideAdvancedMessage;
+ }
+ config.advanced = !config.advanced;
+ });
-chrome.storage.onChanged.addListener(storageChangeHandler);
-initConfig();
+ chrome.storage.onChanged.addListener(storageChangeHandler);
+ initConfig();
+}
function initConfig() {
initializeDisableVoteSubmission();
diff --git a/Extensions/combined/popup.spec.js b/Extensions/combined/popup.spec.js
index 15c8ea2..6aac2f0 100644
--- a/Extensions/combined/popup.spec.js
+++ b/Extensions/combined/popup.spec.js
@@ -13,10 +13,18 @@ const cachedUser = { fullName: "Test User", imageUrl: "https://example.org/avata
describe("popup account consent lifecycle", () => {
let readCachedSession;
let removeConsent;
+ let removeCoreConsent;
let consent;
beforeEach(async () => {
jest.resetModules();
+ const permissionRemovedListeners = new Set();
+ const coreConsentListeners = new Set();
+ require("./src/core-data-consent").onCoreDataPermissionChanged.mockImplementation((listener) => {
+ coreConsentListeners.add(listener);
+ return () => coreConsentListeners.delete(listener);
+ });
+ removeCoreConsent = () => [...coreConsentListeners].forEach((listener) => listener(false));
consent = true;
document.documentElement.innerHTML = fs.readFileSync(path.join(__dirname, "popup.html"), "utf8");
global.chrome = {
@@ -28,8 +36,13 @@ describe("popup account consent lifecycle", () => {
request: jest.fn(() => Promise.resolve(consent)),
onRemoved: {
addListener: (listener) => {
- removeConsent = () => listener({ data_collection: ["authenticationInfo"] });
+ permissionRemovedListeners.add(listener);
+ removeConsent = () =>
+ [...permissionRemovedListeners].forEach((listener) =>
+ listener({ data_collection: ["authenticationInfo"] }),
+ );
},
+ removeListener: (listener) => permissionRemovedListeners.delete(listener),
},
},
identity: { getRedirectURL: jest.fn() },
@@ -135,6 +148,31 @@ describe("popup account consent lifecycle", () => {
expectLoggedOut();
});
+ test("does not restore an account when core consent is removed during the final account-permission query", async () => {
+ let resolvePermission;
+ fetch.mockResolvedValue({
+ json: async () => {
+ chrome.permissions.getAll.mockImplementationOnce(() => new Promise((resolve) => (resolvePermission = resolve)));
+ return { valid: true, membershipTier: "premium" };
+ },
+ });
+ readCachedSession({ patreonUser: cachedUser, patreonSessionToken: "test-token" });
+ await flushPromises();
+ expect(typeof resolvePermission).toBe("function");
+
+ removeCoreConsent();
+ resolvePermission({ data_collection: ["authenticationInfo"] });
+ await flushPromises();
+
+ expect(chrome.storage.sync.set).not.toHaveBeenCalled();
+ expect(chrome.storage.sync.remove).toHaveBeenCalledWith([
+ "patreonAuthenticated",
+ "patreonUser",
+ "patreonSessionToken",
+ ]);
+ expectLoggedOut();
+ });
+
test.each(["revocation", "logout"])(
"does not restore an account or avatar from verification after %s",
async (event) => {
diff --git a/Extensions/combined/ryd.background-changelog.spec.js b/Extensions/combined/ryd.background-changelog.spec.js
index 6f24088..c55a188 100644
--- a/Extensions/combined/ryd.background-changelog.spec.js
+++ b/Extensions/combined/ryd.background-changelog.spec.js
@@ -1,4 +1,9 @@
jest.mock("../common/vote-client", () => ({ createVoteClient: jest.fn() }));
+jest.mock("./src/core-data-consent", () => ({
+ hasCoreDataPermission: jest.fn(),
+ onCoreDataPermissionChanged: jest.fn(),
+ fetchWithCoreConsent: jest.fn(),
+}));
jest.mock("./src/data-collection-permissions", () => ({
hasAuthenticationDataPermission: jest.fn(),
onAuthenticationDataPermissionRemoved: jest.fn(),
@@ -14,9 +19,11 @@ describe("background changelog lifecycle", () => {
let startupListener;
let stored;
- beforeEach(() => {
+ beforeEach(async () => {
jest.resetModules();
stored = {};
+ require("./src/data-collection-permissions").usesFirefoxDataCollectionConsent.mockReturnValue(false);
+ require("./src/core-data-consent").hasCoreDataPermission.mockResolvedValue(false);
require("../common/vote-client").createVoteClient.mockReturnValue({ ensureRegistered: () => Promise.resolve({}) });
global.__RYD_LIVE_TEST_BUILD__ = false;
global.chrome = {
@@ -54,6 +61,8 @@ describe("background changelog lifecycle", () => {
global.browser = global.chrome;
jest.spyOn(console, "debug").mockImplementation(() => {});
require("./ryd.background");
+ await flushCallbacks();
+ require("./src/core-data-consent").hasCoreDataPermission.mockClear();
});
afterEach(() => {
@@ -69,8 +78,68 @@ describe("background changelog lifecycle", () => {
expect(chrome.tabs.create).toHaveBeenCalledTimes(1);
expect(chrome.tabs.create).toHaveBeenCalledWith({ url: CHANGELOG_URL }, expect.any(Function));
expect(stored).toEqual({ lastShownChangelogVersion: VERSION });
+ expect(require("./src/core-data-consent").hasCoreDataPermission).not.toHaveBeenCalled();
});
+ test.each([false, true])(
+ "opens Firefox setup on a fresh install with missing consent (temporary=%s)",
+ async (temporary) => {
+ require("./src/data-collection-permissions").usesFirefoxDataCollectionConsent.mockReturnValue(true);
+ installedListener({ reason: "install", temporary });
+ await flushCallbacks();
+ expect(chrome.tabs.create).toHaveBeenCalledTimes(2);
+ expect(chrome.tabs.create).toHaveBeenCalledWith({ url: CHANGELOG_URL, active: false }, expect.any(Function));
+ expect(chrome.tabs.create).toHaveBeenCalledWith(
+ { url: "moz-extension://test/consent.html" },
+ expect.any(Function),
+ );
+ expect(require("./src/core-data-consent").hasCoreDataPermission).toHaveBeenCalledWith({ queryBackground: false });
+ expect(stored).toEqual({ lastShownChangelogVersion: VERSION });
+ },
+ );
+
+ test("skips Firefox setup when core consent is already granted", async () => {
+ require("./src/data-collection-permissions").usesFirefoxDataCollectionConsent.mockReturnValue(true);
+ require("./src/core-data-consent").hasCoreDataPermission.mockResolvedValue(true);
+ installedListener({ reason: "install" });
+ await flushCallbacks();
+ expect(chrome.tabs.create).toHaveBeenCalledTimes(1);
+ expect(chrome.tabs.create).toHaveBeenCalledWith({ url: CHANGELOG_URL }, expect.any(Function));
+ });
+
+ test("a delayed changelog storage read does not take focus from Firefox setup", async () => {
+ require("./src/data-collection-permissions").usesFirefoxDataCollectionConsent.mockReturnValue(true);
+ let readChangelogStorage;
+ chrome.storage.local.get.mockImplementationOnce((keys, callback) => {
+ readChangelogStorage = callback;
+ });
+ installedListener({ reason: "install" });
+ await flushCallbacks();
+ expect(chrome.tabs.create).toHaveBeenCalledTimes(1);
+ expect(chrome.tabs.create).toHaveBeenLastCalledWith(
+ { url: "moz-extension://test/consent.html" },
+ expect.any(Function),
+ );
+ readChangelogStorage({});
+ await flushCallbacks();
+ expect(chrome.tabs.create).toHaveBeenLastCalledWith({ url: CHANGELOG_URL, active: false }, expect.any(Function));
+ expect(stored).toEqual({ lastShownChangelogVersion: VERSION });
+ });
+
+ test.each(["update", "browser_update", "chrome_update"])(
+ "does not open Firefox permission setup on %s or subsequent startup",
+ async (reason) => {
+ require("./src/data-collection-permissions").usesFirefoxDataCollectionConsent.mockReturnValue(true);
+ stored.lastShownChangelogVersion = VERSION;
+ installedListener({ reason, previousVersion: "3.0.0.18" });
+ await flushCallbacks();
+ startupListener();
+ await flushCallbacks();
+ expect(chrome.tabs.create).not.toHaveBeenCalled();
+ expect(require("./src/core-data-consent").hasCoreDataPermission).not.toHaveBeenCalled();
+ },
+ );
+
test.each(["install", "update"])("preserves already-seen suppression on %s", async (reason) => {
stored.lastShownChangelogVersion = "4.0.4";
installedListener({ reason, temporary: true, previousVersion: "4.0.4" });
diff --git a/Extensions/combined/ryd.background.js b/Extensions/combined/ryd.background.js
index d9c64c3..5ad4341 100644
--- a/Extensions/combined/ryd.background.js
+++ b/Extensions/combined/ryd.background.js
@@ -3,6 +3,8 @@ import { createVoteClient } from "../common/vote-client";
import { createBrowserCredentialStore } from "./src/vote-client-adapter";
import { loginWithGitHub } from "./src/github-auth";
import { createDiscussionBackground } from "./src/discussion-background";
+import { fetchWithAccountConsent } from "./src/account-data-consent";
+import { isFirefoxExtension, isNormalMessageSender } from "./src/private-browsing";
import {
coreConsentError,
fetchWithCoreConsent,
@@ -58,6 +60,7 @@ const discussionBackground = createDiscussionBackground({
api,
voteClient,
fetchImpl: fetchWithCoreConsent,
+ accountFetchImpl: fetchWithAccountConsent,
hasConsent: async () =>
(await hasCoreDataPermission({ queryBackground: false })) &&
(!usesFirefoxDataCollectionConsent() || (await hasAuthenticationDataPermission({ queryBackground: false }))),
@@ -71,6 +74,7 @@ function broadcastPatreonStatus(authenticated, user, sessionToken, generation =
if (generation !== authenticationGeneration) return;
tabs
.filter((tab) => {
+ if (isFirefoxExtension(api) && tab.incognito !== false) return false;
try {
const hostname = new URL(tab.url).hostname;
return hostname === "youtube.com" || hostname.endsWith(".youtube.com");
@@ -245,6 +249,18 @@ function extractOAuthParams(responseUrl) {
}
api.runtime.onMessage.addListener((request, sender, sendResponse) => {
+ if (request?.message === "ryd_has_core_consent" || isNormalMessageSender(api, sender)) {
+ return handleMessage(request, sender, sendResponse);
+ }
+ sendResponse({
+ success: false,
+ ok: false,
+ granted: false,
+ error: { code: "private_browsing", message: "Only count display is available in private windows." },
+ });
+});
+
+function handleMessage(request, sender, sendResponse) {
if (request?.message === "ryd_open_consent") {
if (sender.id !== api.runtime.id || sender.url !== api.runtime.getURL("consent.html")) {
sendResponse({ success: false });
@@ -256,13 +272,16 @@ api.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (consentPageTabId !== null) {
try {
const existing = await tabs.get(consentPageTabId);
- if (existing.url === api.runtime.getURL("consent.html")) {
+ if (existing.url === api.runtime.getURL("consent.html") && existing.incognito === false) {
await tabs.update(consentPageTabId, { active: true });
return;
}
} catch (_) {}
}
- const tab = await tabs.create({ url: api.runtime.getURL("consent.html") });
+ const tab = await tabs.create({
+ url: api.runtime.getURL("consent.html"),
+ ...(Number.isInteger(sender.tab?.windowId) ? { windowId: sender.tab.windowId } : {}),
+ });
consentPageTabId = tab.id;
})().finally(() => {
consentPageOpening = null;
@@ -381,7 +400,7 @@ api.runtime.onMessage.addListener((request, sender, sendResponse) => {
? chrome.identity.getRedirectURL()
: "";
- const startRes = await fetchWithCoreConsent(
+ const startRes = await fetchWithAccountConsent(
getApiEndpoint(`/api/auth/oauth/login?redirectUri=${encodeURIComponent(redirectUri)}`),
);
const startData = await startRes.json();
@@ -396,7 +415,7 @@ api.runtime.onMessage.addListener((request, sender, sendResponse) => {
await requireCurrentAuthenticationConsent(generation);
- const exchangeRes = await fetchWithCoreConsent(getApiEndpoint("/api/auth/oauth/exchange"), {
+ const exchangeRes = await fetchWithAccountConsent(getApiEndpoint("/api/auth/oauth/exchange"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -448,7 +467,7 @@ api.runtime.onMessage.addListener((request, sender, sendResponse) => {
: "";
const authData = await loginWithGitHub({
- fetchImpl: fetchWithCoreConsent,
+ fetchImpl: fetchWithAccountConsent,
redirectUri,
launchWebAuthFlow,
requireConsent: () => requireCurrentAuthenticationConsent(generation),
@@ -471,12 +490,12 @@ api.runtime.onMessage.addListener((request, sender, sendResponse) => {
})();
return true;
}
-});
+}
-function openChangelogTab(version) {
+function openChangelogTab(version, { active } = {}) {
try {
const url = getChangelogUrl();
- api.tabs.create({ url }, () => {
+ api.tabs.create({ url, ...(active === false ? { active: false } : {}) }, () => {
if (api.runtime.lastError) {
console.debug("Changelog tab could not open:", api.runtime.lastError.message);
return;
@@ -574,7 +593,7 @@ function persistChangelogVersion(version) {
}
}
-function maybeShowChangelog(details) {
+function maybeShowChangelog(details, { active } = {}) {
const reason = details?.reason;
if (!reason) {
return;
@@ -595,7 +614,7 @@ function maybeShowChangelog(details) {
const showImmediately = reason === "install" || details.temporary === true;
const showChangelog = () => {
- openChangelogTab(currentVersion || null);
+ openChangelogTab(currentVersion || null, { active });
};
if (!storage || typeof storage.get !== "function") {
@@ -646,8 +665,26 @@ function maybeShowChangelog(details) {
}
}
+async function handleInstallation(details) {
+ const needsConsent =
+ details?.reason === "install" &&
+ usesFirefoxDataCollectionConsent() &&
+ !(await hasCoreDataPermission({ queryBackground: false }));
+ maybeShowChangelog(details, { active: needsConsent ? false : undefined });
+ if (!needsConsent) return;
+
+ try {
+ api.tabs.create({ url: api.runtime.getURL("consent.html") }, (tab) => {
+ if (api.runtime.lastError) return;
+ consentPageTabId = tab?.id ?? null;
+ });
+ } catch (error) {
+ console.debug("Failed to open permission setup", error);
+ }
+}
+
api.runtime.onInstalled.addListener((details) => {
- maybeShowChangelog(details);
+ void handleInstallation(details);
});
if (api?.runtime?.onStartup && typeof api.runtime.onStartup.addListener === "function") {
diff --git a/Extensions/combined/ryd.background.spec.js b/Extensions/combined/ryd.background.spec.js
index babb446..53042ce 100644
--- a/Extensions/combined/ryd.background.spec.js
+++ b/Extensions/combined/ryd.background.spec.js
@@ -6,6 +6,7 @@ jest.mock("./src/core-data-consent", () => ({
onCoreDataPermissionChanged: jest.fn(),
}));
+const normalSender = { id: "extension-id", tab: { id: 1, incognito: false, windowId: 1 } };
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0));
const startData = () => {
const loginUrl = fetch.mock.calls.filter(([url]) => url.includes("/github/login?")).at(-1)?.[0];
@@ -35,8 +36,10 @@ describe("background account consent lifecycle", () => {
beforeEach(() => {
jest.resetModules();
+ const permissionRemovedListeners = new Set();
consent = true;
require("./src/core-data-consent").hasCoreDataPermission.mockResolvedValue(true);
+ require("./src/core-data-consent").onCoreDataPermissionChanged.mockReturnValue(() => {});
originalCrypto = Object.getOwnPropertyDescriptor(globalThis, "crypto");
Object.defineProperty(globalThis, "crypto", {
configurable: true,
@@ -66,8 +69,13 @@ describe("background account consent lifecycle", () => {
contains: jest.fn().mockResolvedValue(true),
onRemoved: {
addListener: (listener) => {
- removeConsent = () => listener({ data_collection: ["authenticationInfo"] });
+ permissionRemovedListeners.add(listener);
+ removeConsent = () =>
+ [...permissionRemovedListeners].forEach((listener) =>
+ listener({ data_collection: ["authenticationInfo"] }),
+ );
},
+ removeListener: (listener) => permissionRemovedListeners.delete(listener),
},
},
identity: {
@@ -83,10 +91,12 @@ describe("background account consent lifecycle", () => {
onChanged: { addListener: jest.fn() },
},
tabs: {
- get: jest.fn(async () => ({ id: 9, url: "moz-extension://fixture/consent.html" })),
+ get: jest.fn(async () => ({ id: 9, url: "moz-extension://fixture/consent.html", incognito: false })),
create: jest.fn(async () => ({ id: 9 })),
update: jest.fn(async () => ({ id: 9 })),
- query: jest.fn((query, callback) => callback([{ id: 1, url: "https://www.youtube.com/watch?v=video123456" }])),
+ query: jest.fn((query, callback) =>
+ callback([{ id: 1, url: "https://www.youtube.com/watch?v=video123456", incognito: false }]),
+ ),
sendMessage: jest.fn(),
},
};
@@ -147,7 +157,7 @@ describe("background account consent lifecycle", () => {
"data:text/plain,youtube.com",
"blob:https://www.youtube.com/test",
];
- const youtubeTabs = youtubeUrls.map((url, id) => ({ id, url }));
+ const youtubeTabs = youtubeUrls.map((url, id) => ({ id, url, incognito: false }));
const otherTabs = otherUrls.map((url, index) => ({ id: youtubeTabs.length + index, url }));
chrome.tabs.query.mockImplementation((query, callback) => callback([...otherTabs, ...youtubeTabs]));
const user = { fullName: "Test" };
@@ -159,7 +169,7 @@ describe("background account consent lifecycle", () => {
const statusMessages = () =>
chrome.tabs.sendMessage.mock.calls.filter(([, payload]) => payload.message === "patreon_status_changed");
- messageListener({ message }, {}, response);
+ messageListener({ message }, normalSender, response);
await flushPromises();
expect(response).toHaveBeenCalledWith({ success: true, user });
@@ -172,7 +182,7 @@ describe("background account consent lifecycle", () => {
);
chrome.tabs.sendMessage.mockClear();
- messageListener({ message: "patreon_logout" }, {}, jest.fn());
+ messageListener({ message: "patreon_logout" }, normalSender, jest.fn());
await flushPromises();
expect(statusMessages()).toEqual(
@@ -196,16 +206,16 @@ describe("background account consent lifecycle", () => {
ok: true,
json: async () => ({ success: true, user: { fullName: "Test" }, sessionToken: "test-token" }),
});
- messageListener({ message: "patreon_oauth_login" }, {}, jest.fn());
+ messageListener({ message: "patreon_oauth_login" }, normalSender, jest.fn());
await flushPromises();
expect(completeQuery).toEqual(expect.any(Function));
if (event === "revocation") revoke();
- else messageListener({ message: "patreon_logout" }, {}, jest.fn());
+ else messageListener({ message: "patreon_logout" }, normalSender, jest.fn());
await flushPromises();
const callsBeforeCompletion = chrome.tabs.sendMessage.mock.calls.slice();
- completeQuery([{ id: 1, url: "https://www.youtube.com/watch?v=test" }]);
+ completeQuery([{ id: 1, url: "https://www.youtube.com/watch?v=test", incognito: false }]);
expect(chrome.tabs.sendMessage.mock.calls).toEqual(callsBeforeCompletion);
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
@@ -225,9 +235,71 @@ describe("background account consent lifecycle", () => {
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
});
+ test.each([
+ "send_vote",
+ "register",
+ "patreon_oauth_login",
+ "github_oauth_login",
+ "patreon_logout",
+ "ryd_discussion",
+ "set_state",
+ "send_links",
+ "ryd_open_tab",
+ "ryd_open_consent",
+ "ryd_has_authentication_consent",
+ ])("private %s is rejected before shared work", async (message) => {
+ const response = jest.fn();
+ messageListener(
+ {
+ message,
+ videoId: "abcdefghijk",
+ vote: -1,
+ videoIds: Array(20).fill("abcdefghijk"),
+ url: "https://example.org/",
+ },
+ { ...normalSender, tab: { id: 2, incognito: true }, url: "moz-extension://fixture/consent.html" },
+ response,
+ );
+ await flushPromises();
+ expect(response).toHaveBeenCalledWith(
+ expect.objectContaining({
+ success: false,
+ granted: false,
+ error: expect.objectContaining({ code: "private_browsing" }),
+ }),
+ );
+ const client = require("../common/vote-client").createVoteClient.mock.results[0].value;
+ expect(client.ensureRegistered).not.toHaveBeenCalled();
+ expect(client.getRegisteredUser).not.toHaveBeenCalled();
+ expect(client.submitVote).not.toHaveBeenCalled();
+ expect(fetch).not.toHaveBeenCalled();
+ expect(chrome.storage.sync.set).not.toHaveBeenCalled();
+ expect(chrome.storage.sync.remove).not.toHaveBeenCalled();
+ expect(chrome.tabs.create).not.toHaveBeenCalled();
+ expect(chrome.identity.launchWebAuthFlow).not.toHaveBeenCalled();
+ });
+
+ test("private tabs can read consent but receive no account broadcasts", async () => {
+ chrome.tabs.query.mockImplementation((query, callback) =>
+ callback([
+ { id: 1, url: "https://www.youtube.com/watch?v=test", incognito: false },
+ { id: 2, url: "https://www.youtube.com/watch?v=test", incognito: true },
+ ]),
+ );
+ const response = jest.fn();
+ messageListener({ message: "ryd_has_core_consent" }, { id: "extension-id", tab: { incognito: true } }, response);
+ messageListener({ message: "patreon_logout" }, normalSender, jest.fn());
+ await flushPromises();
+ expect(response).toHaveBeenCalledWith({ granted: true });
+ const recipients = chrome.tabs.sendMessage.mock.calls
+ .filter(([, payload]) => payload.message === "patreon_status_changed")
+ .map(([id]) => id);
+ expect(recipients).toEqual([1]);
+ });
+
test.each([-1, 0, 1])("submits vote %s through the client that registers on demand", async (vote) => {
const response = jest.fn();
- messageListener({ message: "send_vote", videoId: "video123456", vote }, {}, response);
+ messageListener({ message: "send_vote", videoId: "video123456", vote }, normalSender, response);
await flushPromises();
const voteClient = require("../common/vote-client").createVoteClient.mock.results[0].value;
expect(voteClient.submitVote).toHaveBeenCalledWith("video123456", vote);
@@ -239,7 +311,7 @@ describe("background account consent lifecycle", () => {
async (message) => {
require("./src/core-data-consent").hasCoreDataPermission.mockResolvedValue(false);
const response = jest.fn();
- messageListener({ message, videoId: "video123456", vote: -1 }, {}, response);
+ messageListener({ message, videoId: "video123456", vote: -1 }, normalSender, response);
await flushPromises();
const client = require("../common/vote-client").createVoteClient.mock.results[0].value;
expect(client.ensureRegistered).not.toHaveBeenCalled();
@@ -251,7 +323,7 @@ describe("background account consent lifecycle", () => {
test("a standalone registration request does not create an ID", async () => {
const response = jest.fn();
- messageListener({ message: "register" }, {}, response);
+ messageListener({ message: "register" }, normalSender, response);
await flushPromises();
expect(response).toHaveBeenCalledWith({ success: false, error: "vote_required" });
expect(
@@ -261,7 +333,7 @@ describe("background account consent lifecycle", () => {
test("only the explainer can open the consent page and repeated clicks reuse the tab", async () => {
const response = jest.fn();
- const sender = { id: "extension-id", url: "moz-extension://fixture/consent.html" };
+ const sender = { ...normalSender, url: "moz-extension://fixture/consent.html" };
messageListener({ message: "ryd_open_consent" }, { ...sender, url: "https://www.youtube.com/" }, response);
expect(response).toHaveBeenCalledWith({ success: false });
expect(chrome.tabs.create).not.toHaveBeenCalled();
@@ -275,7 +347,7 @@ describe("background account consent lifecycle", () => {
});
test("reopens the explainer if its previous tab has navigated away", async () => {
- const sender = { id: "extension-id", url: "moz-extension://fixture/consent.html" };
+ const sender = { ...normalSender, url: "moz-extension://fixture/consent.html" };
messageListener({ message: "ryd_open_consent" }, sender, jest.fn());
await flushPromises();
chrome.tabs.get.mockResolvedValue({ id: 9, url: "https://example.org/" });
@@ -290,7 +362,7 @@ describe("background account consent lifecycle", () => {
async (message) => {
consent = false;
const response = jest.fn();
- messageListener({ message }, {}, response);
+ messageListener({ message }, normalSender, response);
await flushPromises();
expect(fetch).not.toHaveBeenCalled();
expect(chrome.identity.launchWebAuthFlow).not.toHaveBeenCalled();
@@ -303,7 +375,7 @@ describe("background account consent lifecycle", () => {
async (message) => {
chrome.permissions.getAll.mockResolvedValue({ permissions: ["identity"], origins: [] });
const response = jest.fn();
- messageListener({ message }, {}, response);
+ messageListener({ message }, normalSender, response);
await flushPromises();
expect(fetch).not.toHaveBeenCalled();
expect(chrome.identity.launchWebAuthFlow).not.toHaveBeenCalled();
@@ -316,7 +388,7 @@ describe("background account consent lifecycle", () => {
async (message) => {
const start = deferred();
fetch.mockReturnValueOnce(start.promise);
- messageListener({ message }, {}, jest.fn());
+ messageListener({ message }, normalSender, jest.fn());
await flushPromises();
revoke();
start.resolve({ ok: true, json: async () => startData() });
@@ -332,9 +404,9 @@ describe("background account consent lifecycle", () => {
const oauth = deferred();
fetch.mockResolvedValue({ ok: true, json: async () => startData() });
chrome.identity.launchWebAuthFlow.mockReturnValue(oauth.promise);
- messageListener({ message }, {}, jest.fn());
+ messageListener({ message }, normalSender, jest.fn());
await flushPromises();
- messageListener({ message: "patreon_logout" }, {}, jest.fn());
+ messageListener({ message: "patreon_logout" }, normalSender, jest.fn());
oauth.resolve("https://extension.example/callback?code=test&state=test");
await flushPromises();
expect(fetch).toHaveBeenCalledTimes(1);
@@ -348,7 +420,7 @@ describe("background account consent lifecycle", () => {
const exchange = deferred();
fetch.mockResolvedValueOnce({ ok: true, json: async () => startData() }).mockReturnValueOnce(exchange.promise);
const response = jest.fn();
- messageListener({ message }, {}, response);
+ messageListener({ message }, normalSender, response);
await flushPromises();
expect(fetch).toHaveBeenCalledTimes(2);
revoke();
@@ -382,10 +454,10 @@ describe("background account consent lifecycle", () => {
json: async () => ({ success: true, user: { fullName: "Test" }, sessionToken: "test-token" }),
});
const response = jest.fn();
- messageListener({ message: "patreon_oauth_login" }, {}, response);
+ messageListener({ message: "patreon_oauth_login" }, normalSender, response);
await flushPromises();
if (event === "revocation") revoke();
- else messageListener({ message: "patreon_logout" }, {}, jest.fn());
+ else messageListener({ message: "patreon_logout" }, normalSender, jest.fn());
await flushPromises();
expect(chrome.storage.sync.remove).not.toHaveBeenCalled();
saved();
@@ -436,11 +508,11 @@ describe("background account consent lifecycle", () => {
ok: true,
json: async () => ({ success: true, user: { fullName: "New" }, sessionToken: "new-token" }),
});
- messageListener({ message: "patreon_oauth_login" }, {}, jest.fn());
+ messageListener({ message: "patreon_oauth_login" }, normalSender, jest.fn());
await flushPromises();
- messageListener({ message: "patreon_logout" }, {}, jest.fn());
+ messageListener({ message: "patreon_logout" }, normalSender, jest.fn());
const newResponse = jest.fn();
- messageListener({ message: "github_oauth_login" }, {}, newResponse);
+ messageListener({ message: "github_oauth_login" }, normalSender, newResponse);
await flushPromises();
completeOldSave();
await flushPromises();
diff --git a/Extensions/combined/ryd.content-script.js b/Extensions/combined/ryd.content-script.js
index a0bb474..3481a80 100644
--- a/Extensions/combined/ryd.content-script.js
+++ b/Extensions/combined/ryd.content-script.js
@@ -21,6 +21,7 @@ import {
restoreCurrentState,
setInitialState,
storedData,
+ NEUTRAL_STATE,
} from "./src/state";
import { getBrowser, getVideoId, isVideoLoaded } from "./src/utils";
import {
@@ -42,6 +43,7 @@ import { initDiscussion } from "./src/discussion";
import { waitForCoreDataConsent } from "./src/consent-prompt";
import { onCoreDataPermissionChanged } from "./src/core-data-consent";
import { cancelObsoleteVoteDataRequests, clearVoteDataRequestCache } from "./src/vote-data-request";
+import { isPrivateBrowsingContext } from "./src/private-browsing";
import {
actionBarHasHydratedData,
captureShortsNativeControlInventory,
@@ -70,10 +72,34 @@ let pendingShortsMutationSurface = null;
let shortsRouteEpoch = 0;
let observedRouteKey = null;
let coreConsentGranted = false;
+let privatePageActive = true;
+const privateBrowsingContext = isPrivateBrowsingContext();
const pendingNavigation = createPendingNavigationTracker();
const SHORTS_HYDRATION_ATTRIBUTE = "data-ryd-shorts-action-bar-hydrated";
const SHORTS_HYDRATION_REQUEST_EVENT = "ryd-shorts-action-bar-hydration-request";
+function clearPrivateVoteState() {
+ clearVoteDataRequestCache();
+ Object.assign(storedData, { likes: 0, dislikes: 0, previousState: NEUTRAL_STATE, videoId: null });
+}
+
+if (privateBrowsingContext) {
+ window.addEventListener("pagehide", () => {
+ privatePageActive = false;
+ clearPrivateVoteState();
+ clearRenderedVoteState();
+ initializedVideoId = null;
+ isSetInitialStateDone = false;
+ if (jsInitChecktimer !== null) {
+ clearInterval(jsInitChecktimer);
+ jsInitChecktimer = null;
+ }
+ });
+ window.addEventListener("pageshow", () => {
+ privatePageActive = true;
+ });
+}
+
function injectPageWorldHelpers() {
return new Promise((resolve) => {
const script = document.createElement("script");
@@ -107,6 +133,7 @@ onCoreDataPermissionChanged((granted) => {
coreConsentGranted = granted;
if (!granted) {
clearVoteDataRequestCache();
+ if (privateBrowsingContext) clearPrivateVoteState();
storedData.videoId = null;
initializedVideoId = null;
isSetInitialStateDone = false;
@@ -119,13 +146,17 @@ onCoreDataPermissionChanged((granted) => {
const pageWorldHelpersReady = injectPageWorldHelpers();
// Listen before asynchronous initialization so settings changed in another tab
// are applied while the page is still loading.
-getBrowser().storage.onChanged.addListener(storageChangeHandler);
-isStorageListenerRegistered = true;
+if (!privateBrowsingContext) {
+ getBrowser().storage.onChanged.addListener(storageChangeHandler);
+ isStorageListenerRegistered = true;
+}
await initExtConfig();
observeColorTheme();
await pageWorldHelpersReady;
-initPatreonFeatures();
-initDiscussion();
+if (!privateBrowsingContext) {
+ initPatreonFeatures();
+ initDiscussion();
+}
function ensureShortsNavigationObserver() {
if (!isShorts()) {
@@ -308,6 +339,7 @@ function getStableShortsControls(videoId) {
}
async function checkForInitialization() {
+ if (privateBrowsingContext && !privatePageActive) return;
if (!coreConsentGranted) return;
if (initializationCheckRunning) return;
initializationCheckRunning = true;
@@ -341,7 +373,7 @@ async function checkForInitialization() {
} else {
rendered = await setInitialState();
}
- if (rendered === false || !coreConsentGranted) return;
+ if (rendered === false || !coreConsentGranted || (privateBrowsingContext && !privatePageActive)) return;
const currentControls = getButtonControls();
if (
videoId !== getVideoId(window.location.href) ||
@@ -369,13 +401,17 @@ async function checkForInitialization() {
clearInterval(jsInitChecktimer);
jsInitChecktimer = null;
}
- if (!isStorageListenerRegistered) {
+ if (!privateBrowsingContext && !isStorageListenerRegistered) {
getBrowser().storage.onChanged.addListener(storageChangeHandler);
isStorageListenerRegistered = true;
}
}
} catch (exception) {
- console.warn("Initialization failed; retrying when the current controls are ready.", exception);
+ if (privateBrowsingContext) {
+ console.warn("Initialization failed; retrying when the current controls are ready.");
+ } else {
+ console.warn("Initialization failed; retrying when the current controls are ready.", exception);
+ }
} finally {
initializationCheckRunning = false;
}
@@ -405,6 +441,7 @@ const initializationCycle = createInitializationCycleRunner(async () => {
});
function triggerInitializationCycle() {
+ if (privateBrowsingContext && !privatePageActive) return Promise.resolve();
if (!coreConsentGranted) return Promise.resolve();
return initializationCycle.request();
}
@@ -415,6 +452,7 @@ async function setEventListeners() {
document.addEventListener("yt-navigate-start", function () {
clearVoteDataRequestCache();
+ if (privateBrowsingContext) clearPrivateVoteState();
shortsRouteEpoch += 1;
pendingShortsMutationSurface = null;
pendingNavigation.begin(() => {
@@ -523,6 +561,7 @@ function watchControlsNeedInitialization() {
}
setInterval(() => {
+ if (privateBrowsingContext && !privatePageActive) return;
if (!coreConsentGranted) return;
syncShortsRouteEpoch();
if (isShorts()) ensureShortsNavigationObserver();
diff --git a/Extensions/combined/src/account-data-consent.js b/Extensions/combined/src/account-data-consent.js
new file mode 100644
index 0000000..b64e4eb
--- /dev/null
+++ b/Extensions/combined/src/account-data-consent.js
@@ -0,0 +1,53 @@
+import { fetchWithCoreConsent, onCoreDataPermissionChanged } from "./core-data-consent";
+import {
+ hasAuthenticationDataPermission,
+ onAuthenticationDataPermissionRemoved,
+ usesFirefoxDataCollectionConsent,
+} from "./data-collection-permissions";
+
+function accountConsentError() {
+ return new Error("Authentication data consent required.");
+}
+
+function fetchWithAccountConsent(input, options = {}, fetchImpl = globalThis.fetch) {
+ if (!usesFirefoxDataCollectionConsent()) return fetchWithCoreConsent(input, options, fetchImpl);
+
+ return (async () => {
+ const controller = new AbortController();
+ const abort = () => controller.abort();
+ const unsubscribe = onAuthenticationDataPermissionRemoved(abort);
+ const unsubscribeCore = onCoreDataPermissionChanged((granted) => {
+ if (!granted) abort();
+ });
+ const api = typeof browser !== "undefined" ? browser : globalThis.chrome;
+ const messages = typeof api?.permissions?.onRemoved?.addListener === "function" ? null : api?.runtime?.onMessage;
+ const onMessage = (message) => {
+ if (message?.message === "patreon_status_changed" && message.authenticated === false) abort();
+ };
+ messages?.addListener?.(onMessage);
+ options.signal?.addEventListener("abort", abort, { once: true });
+ if (options.signal?.aborted) abort();
+
+ try {
+ const response = await fetchWithCoreConsent(
+ input,
+ { ...options, signal: controller.signal },
+ async (requestInput, requestOptions) => {
+ // Check after the core-consent query, immediately before sending account data.
+ const granted = await hasAuthenticationDataPermission();
+ if (!granted || controller.signal.aborted || requestOptions.signal.aborted) throw accountConsentError();
+ return fetchImpl(requestInput, requestOptions);
+ },
+ );
+ if (!(await hasAuthenticationDataPermission()) || controller.signal.aborted) throw accountConsentError();
+ return response;
+ } finally {
+ unsubscribe();
+ unsubscribeCore();
+ messages?.removeListener?.(onMessage);
+ options.signal?.removeEventListener("abort", abort);
+ }
+ })();
+}
+
+export { fetchWithAccountConsent };
diff --git a/Extensions/combined/src/account-data-consent.spec.js b/Extensions/combined/src/account-data-consent.spec.js
new file mode 100644
index 0000000..8dfa805
--- /dev/null
+++ b/Extensions/combined/src/account-data-consent.spec.js
@@ -0,0 +1,222 @@
+import { fetchWithAccountConsent } from "./account-data-consent";
+import { CORE_DATA_PERMISSIONS } from "./core-data-consent";
+
+const URL = "https://api.example/account";
+const OPTIONS = { headers: { Authorization: "Bearer synthetic-token" } };
+const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
+const deferred = () => {
+ let resolve;
+ const promise = new Promise((done) => (resolve = done));
+ return { promise, resolve };
+};
+
+function event() {
+ const listeners = new Set();
+ return {
+ addListener: (listener) => listeners.add(listener),
+ removeListener: (listener) => listeners.delete(listener),
+ emit: (value) => [...listeners].forEach((listener) => listener(value)),
+ size: () => listeners.size,
+ };
+}
+
+describe("account request consent boundary", () => {
+ let granted;
+ let fetchImpl;
+
+ beforeEach(() => {
+ granted = [...CORE_DATA_PERMISSIONS, "authenticationInfo"];
+ global.browser = {
+ runtime: {
+ getManifest: () => ({ browser_specific_settings: { gecko: { data_collection_permissions: {} } } }),
+ onMessage: event(),
+ sendMessage: jest.fn(),
+ },
+ permissions: {
+ getAll: jest.fn(async () => ({ data_collection: [...granted] })),
+ request: jest.fn(),
+ onAdded: event(),
+ onRemoved: event(),
+ },
+ };
+ fetchImpl = jest.fn(async () => new Response('{"valid":true}'));
+ });
+
+ afterEach(() => {
+ expect(browser.runtime.onMessage.size()).toBe(0);
+ if (browser.permissions) {
+ expect(browser.permissions.onAdded.size()).toBe(0);
+ expect(browser.permissions.onRemoved.size()).toBe(0);
+ }
+ delete global.browser;
+ });
+
+ function revokeAuthentication() {
+ granted = [...CORE_DATA_PERMISSIONS];
+ browser.permissions.onRemoved.emit({ data_collection: ["authenticationInfo"] });
+ }
+
+ test("granted account requests return their response without requesting permissions", async () => {
+ const response = await fetchWithAccountConsent(URL, OPTIONS, fetchImpl);
+ expect(await response.json()).toEqual({ valid: true });
+ expect(fetchImpl).toHaveBeenCalledWith(URL, expect.objectContaining(OPTIONS));
+ expect(browser.permissions.request).not.toHaveBeenCalled();
+ });
+
+ test.each(["missing", "unavailable"])("%s authentication consent blocks account traffic", async (state) => {
+ granted = [...CORE_DATA_PERMISSIONS];
+ if (state === "unavailable") {
+ browser.permissions.getAll
+ .mockResolvedValueOnce({ data_collection: [...CORE_DATA_PERMISSIONS] })
+ .mockRejectedValueOnce(new Error("Unavailable"));
+ }
+ await expect(fetchWithAccountConsent(URL, OPTIONS, fetchImpl)).rejects.toThrow("consent");
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+
+ test("rechecks authentication after a delayed core query even without a removal event", async () => {
+ const coreCheck = deferred();
+ browser.permissions.getAll.mockReturnValueOnce(coreCheck.promise);
+ const pending = fetchWithAccountConsent(URL, OPTIONS, fetchImpl);
+ granted = [...CORE_DATA_PERMISSIONS];
+ coreCheck.resolve({ data_collection: [...CORE_DATA_PERMISSIONS, "authenticationInfo"] });
+ await expect(pending).rejects.toThrow("consent");
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+
+ test.each([false, true])("revocation during a core query blocks traffic after regrant=%s", async (regrant) => {
+ const coreCheck = deferred();
+ browser.permissions.getAll.mockReturnValueOnce(coreCheck.promise);
+ const pending = fetchWithAccountConsent(URL, OPTIONS, fetchImpl);
+ revokeAuthentication();
+ if (regrant) granted.push("authenticationInfo");
+ coreCheck.resolve({ data_collection: [...granted] });
+ await expect(pending).rejects.toThrow("consent");
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+
+ test.each(["authenticationInfo", CORE_DATA_PERMISSIONS[0]])(
+ "revoking %s during the final account query blocks a stale granting result",
+ async (permission) => {
+ const accountCheck = deferred();
+ browser.permissions.getAll
+ .mockResolvedValueOnce({ data_collection: [...granted] })
+ .mockReturnValueOnce(accountCheck.promise);
+ const pending = fetchWithAccountConsent(URL, OPTIONS, fetchImpl);
+ await flush();
+ browser.permissions.onRemoved.emit({ data_collection: [permission] });
+ accountCheck.resolve({ data_collection: [...granted] });
+ await expect(pending).rejects.toThrow("consent");
+ expect(fetchImpl).not.toHaveBeenCalled();
+ },
+ );
+
+ test("revocation aborts in-flight work and discards a body that finishes after regrant", async () => {
+ const body = deferred();
+ let signal;
+ fetchImpl.mockImplementationOnce(async (_, options) => {
+ signal = options.signal;
+ return { arrayBuffer: () => body.promise };
+ });
+ const pending = fetchWithAccountConsent(URL, OPTIONS, fetchImpl);
+ await flush();
+ revokeAuthentication();
+ granted.push("authenticationInfo");
+ expect(signal.aborted).toBe(true);
+ body.resolve(new ArrayBuffer(0));
+ await expect(pending).rejects.toThrow("consent");
+ });
+
+ test("a final permission check discards a completed response if removal events are unavailable", async () => {
+ fetchImpl.mockImplementationOnce(async () => {
+ granted = [...CORE_DATA_PERMISSIONS];
+ return new Response('{"valid":true}');
+ });
+ await expect(fetchWithAccountConsent(URL, OPTIONS, fetchImpl)).rejects.toThrow("consent");
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
+ });
+
+ test("content requests observe account revocation while their background permission query is pending", async () => {
+ delete browser.permissions;
+ const accountCheck = deferred();
+ browser.runtime.sendMessage.mockImplementation(({ message }) =>
+ message === "ryd_has_core_consent" ? Promise.resolve({ granted: true }) : accountCheck.promise,
+ );
+ const pending = fetchWithAccountConsent(URL, OPTIONS, fetchImpl);
+ await flush();
+ browser.runtime.onMessage.emit({ message: "patreon_status_changed", authenticated: false });
+ accountCheck.resolve({ granted: true });
+ await expect(pending).rejects.toThrow("consent");
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+
+ test("content revocation aborts a pending body and rejects its result", async () => {
+ delete browser.permissions;
+ browser.runtime.sendMessage.mockResolvedValue({ granted: true });
+ const body = deferred();
+ let signal;
+ fetchImpl.mockImplementationOnce(async (_, options) => {
+ signal = options.signal;
+ return { arrayBuffer: () => body.promise };
+ });
+ const pending = fetchWithAccountConsent(URL, OPTIONS, fetchImpl);
+ await flush();
+ browser.runtime.onMessage.emit({ message: "patreon_status_changed", authenticated: false });
+ expect(signal.aborted).toBe(true);
+ body.resolve(new ArrayBuffer(0));
+ await expect(pending).rejects.toThrow("consent");
+ });
+
+ test.each(["native", "content"])(
+ "core revocation during the final %s account query discards a buffered response",
+ async (context) => {
+ const accountCheck = deferred();
+ if (context === "native") {
+ browser.permissions.getAll
+ .mockResolvedValueOnce({ data_collection: [...granted] })
+ .mockResolvedValueOnce({ data_collection: [...granted] })
+ .mockResolvedValueOnce({ data_collection: [...granted] })
+ .mockReturnValueOnce(accountCheck.promise);
+ } else {
+ delete browser.permissions;
+ let accountQueries = 0;
+ browser.runtime.sendMessage.mockImplementation(({ message }) => {
+ if (message === "ryd_has_core_consent" || ++accountQueries === 1) return Promise.resolve({ granted: true });
+ return accountCheck.promise;
+ });
+ }
+ const pending = fetchWithAccountConsent(URL, OPTIONS, fetchImpl);
+ await flush();
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
+ if (context === "native") {
+ browser.permissions.onRemoved.emit({ data_collection: [CORE_DATA_PERMISSIONS[0]] });
+ accountCheck.resolve({ data_collection: [...granted] });
+ } else {
+ browser.runtime.onMessage.emit({ message: "ryd_core_consent_changed", granted: false });
+ accountCheck.resolve({ granted: true });
+ }
+ await expect(pending).rejects.toThrow("consent");
+ },
+ );
+
+ test("caller cancellation prevents a pending account request from starting", async () => {
+ const accountCheck = deferred();
+ browser.permissions.getAll
+ .mockResolvedValueOnce({ data_collection: [...granted] })
+ .mockReturnValueOnce(accountCheck.promise);
+ const controller = new AbortController();
+ const pending = fetchWithAccountConsent(URL, { ...OPTIONS, signal: controller.signal }, fetchImpl);
+ await flush();
+ controller.abort();
+ accountCheck.resolve({ data_collection: [...granted] });
+ await expect(pending).rejects.toThrow("consent");
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+
+ test("other browsers keep their ordinary request behavior", async () => {
+ browser.runtime.getManifest = () => ({});
+ await fetchWithAccountConsent(URL, OPTIONS, fetchImpl);
+ expect(fetchImpl).toHaveBeenCalledWith(URL, OPTIONS);
+ expect(browser.permissions.getAll).not.toHaveBeenCalled();
+ });
+});
diff --git a/Extensions/combined/src/amo-policy.spec.js b/Extensions/combined/src/amo-policy.spec.js
index d10cc13..5d2c6ea 100644
--- a/Extensions/combined/src/amo-policy.spec.js
+++ b/Extensions/combined/src/amo-policy.spec.js
@@ -12,7 +12,7 @@ describe("Firefox AMO policy declarations", () => {
const manifest = JSON.parse(readExtensionFile("manifest-firefox.json"));
const gecko = manifest.browser_specific_settings.gecko;
- expect(manifest.incognito).toBe("not_allowed");
+ expect(manifest.incognito).toBe("spanning");
expect(gecko.id).toBe("{762f9885-5a13-4abd-9c77-433dcd38b8fd}");
expect(gecko.strict_min_version).toBe("140.0");
expect(gecko.data_collection_permissions).toEqual({
diff --git a/Extensions/combined/src/bar.js b/Extensions/combined/src/bar.js
index 7f2e5b5..0bb1545 100644
--- a/Extensions/combined/src/bar.js
+++ b/Extensions/combined/src/bar.js
@@ -1,3 +1,4 @@
+import { logger } from "./logging";
import { getButtons, getDislikeButton, getLikeButton } from "./buttons";
import { extConfig, isMobile, isLikesDisabled, isNewDesign, isRoundedDesign, isShorts, isVideoLiked } from "./state";
import { getColorFromTheme, getElementWidth, getVideoId, isInViewport, querySelector } from "./utils";
@@ -192,7 +193,7 @@ function createRateBar(likes, dislikes) {
}
}
} else {
- console.log("removing bar");
+ logger.log("removing bar");
if (rateBar) {
(rateBar.closest(".ryd-tooltip") ?? rateBar).remove();
}
diff --git a/Extensions/combined/src/buttons.js b/Extensions/combined/src/buttons.js
index ccb8efc..f8b0c8f 100644
--- a/Extensions/combined/src/buttons.js
+++ b/Extensions/combined/src/buttons.js
@@ -1,5 +1,6 @@
import { isMobile, isShorts, extConfig } from "./state";
import { getVideoId, querySelector, querySelectorAll } from "./utils";
+import { isPrivateBrowsingContext } from "./private-browsing";
import {
actionBarHasHydratedData,
getShortsIdentityLinkVideoIds,
@@ -310,6 +311,7 @@ function isSyntheticShortsDislike(dislikeButton) {
function setSyntheticShortsDislikePressed(pressed, dislikeButton = getDislikeButton()) {
if (!isSyntheticShortsDislike(dislikeButton)) return false;
+ if (isPrivateBrowsingContext()) pressed = false;
dislikeButton.classList.toggle("style-default-active", pressed);
dislikeButton.classList.toggle("style-text", !pressed);
@@ -319,6 +321,7 @@ function setSyntheticShortsDislikePressed(pressed, dislikeButton = getDislikeBut
function setSyntheticShortsDislikeEnabled(enabled, dislikeButton = getDislikeButton()) {
if (!isSyntheticShortsDislike(dislikeButton)) return false;
+ if (isPrivateBrowsingContext()) enabled = false;
const button = dislikeButton.querySelector("button");
if (!button) return false;
diff --git a/Extensions/combined/src/buttons.spec.js b/Extensions/combined/src/buttons.spec.js
index 7676af2..fb14abe 100644
--- a/Extensions/combined/src/buttons.spec.js
+++ b/Extensions/combined/src/buttons.spec.js
@@ -1,5 +1,11 @@
/** @jest-environment jsdom */
+jest.mock("./private-browsing", () => ({
+ ...jest.requireActual("./private-browsing"),
+ isPrivateBrowsingContext: jest.fn(),
+}));
+
+const { isPrivateBrowsingContext } = require("./private-browsing");
const { extConfig, refreshReactionPresentation } = require("./state");
const {
ensureSyntheticShortsDislikeButton,
@@ -94,6 +100,7 @@ function setNativeTextBoxes() {
describe("desktop watch button ownership", () => {
beforeEach(() => {
+ isPrivateBrowsingContext.mockReturnValue(false);
history.replaceState({}, "", "/watch?v=BBBBBBBBBBB");
extConfig.selectors.buttons.regular.desktopMenu = ["ytd-menu-renderer.ytd-watch-metadata > div"];
extConfig.selectors.buttons.regular.desktopNoMenu = ["#top-level-buttons-computed"];
@@ -686,6 +693,43 @@ describe("desktop watch button ownership", () => {
expect(dislike.classList.contains("style-default-active")).toBe(true);
});
+ test("keeps a private synthetic Shorts count disabled and unpressed through direct setters", () => {
+ isPrivateBrowsingContext.mockReturnValue(true);
+ history.replaceState({}, "", "/shorts/BBBBBBBBBBB");
+ document.body.innerHTML = `
+
+
+
+
+
+ `;
+ const actionBar = document.querySelector("reel-action-bar-view-model");
+ setBox(actionBar);
+ const dislike = ensureSyntheticShortsDislikeButton(actionBar, {
+ currentVideoId: "BBBBBBBBBBB",
+ isHydrated: true,
+ isStable: true,
+ });
+ const nativeLike = actionBar.querySelector("like-button-view-model button");
+ const nativeClick = jest.fn();
+ nativeLike.addEventListener("click", nativeClick);
+ const syntheticClick = jest.fn();
+ dislike.querySelector("button").addEventListener("click", syntheticClick);
+
+ setSyntheticShortsDislikeEnabled(true, dislike);
+ setSyntheticShortsDislikePressed(true, dislike);
+ dislike.querySelector("button").click();
+
+ expect(dislike.querySelector("button").disabled).toBe(true);
+ expect(dislike.querySelector("button").getAttribute("aria-disabled")).toBe("true");
+ expect(dislike.querySelector("button").getAttribute("aria-pressed")).toBe("false");
+ expect(dislike.classList.contains("style-default-active")).toBe(false);
+ expect(getButtonControls(actionBar).ready).toBe(true);
+ expect(syntheticClick).not.toHaveBeenCalled();
+ expect(nativeClick).not.toHaveBeenCalled();
+ expect(nativeLike.getAttribute("aria-pressed")).toBe("true");
+ });
+
test("does not mutate an offscreen URL-matching Shorts placeholder before activation", () => {
history.replaceState({}, "", "/shorts/BBBBBBBBBBB");
document.body.innerHTML = `
diff --git a/Extensions/combined/src/changelog/index.js b/Extensions/combined/src/changelog/index.js
index 17da7a5..e6c7909 100644
--- a/Extensions/combined/src/changelog/index.js
+++ b/Extensions/combined/src/changelog/index.js
@@ -26,6 +26,8 @@ function applyLocaleMetadata() {
if (browserLocale) {
document.documentElement.lang = browserLocale;
}
+ const direction = chrome?.i18n?.getMessage?.("@@bidi_dir");
+ if (direction === "rtl" || direction === "ltr") document.documentElement.dir = direction;
} catch (error) {
console.debug("Unable to resolve UI locale", error);
}
diff --git a/Extensions/combined/src/changelog/index.spec.js b/Extensions/combined/src/changelog/index.spec.js
new file mode 100644
index 0000000..a500120
--- /dev/null
+++ b/Extensions/combined/src/changelog/index.spec.js
@@ -0,0 +1,54 @@
+/** @jest-environment jsdom */
+const fs = require("node:fs");
+const path = require("node:path");
+
+jest.mock("../utils", () => ({
+ getBrowser: () => "Chrome",
+ localize: (key) => require("../../_locales/en/messages.json")[key]?.message || "",
+}));
+
+const { initChangelogPage } = require("./index");
+
+describe("release page locale metadata", () => {
+ beforeEach(() => {
+ jest.spyOn(document, "readyState", "get").mockReturnValue("complete");
+ document.documentElement.removeAttribute("lang");
+ document.documentElement.removeAttribute("dir");
+ document.documentElement.innerHTML = fs.readFileSync(
+ path.join(__dirname, "../../changelog/4/changelog_4.1.html"),
+ "utf8",
+ );
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ delete global.chrome;
+ document.documentElement.removeAttribute("lang");
+ document.documentElement.removeAttribute("dir");
+ });
+
+ test.each([
+ ["fa", "rtl"],
+ ["en", "ltr"],
+ ])("uses %s language and %s direction while localizing the release page", (locale, direction) => {
+ global.chrome = { i18n: { getMessage: (key) => ({ "@@ui_locale": locale, "@@bidi_dir": direction })[key] } };
+
+ initChangelogPage();
+
+ expect(document.documentElement.lang).toBe(locale);
+ expect(document.documentElement.dir).toBe(direction);
+ expect(document.querySelector("h1").textContent).not.toContain("__MSG_");
+ expect(document.documentElement.innerHTML).not.toContain("__MSG_");
+ });
+
+ test.each(["", "auto"])("preserves document metadata when locale metadata is %j", (metadata) => {
+ document.documentElement.lang = "en";
+ document.documentElement.dir = "ltr";
+ global.chrome = { i18n: { getMessage: (key) => (key === "@@bidi_dir" ? metadata : "") } };
+
+ initChangelogPage();
+
+ expect(document.documentElement.lang).toBe("en");
+ expect(document.documentElement.dir).toBe("ltr");
+ });
+});
diff --git a/Extensions/combined/src/consent-prompt.js b/Extensions/combined/src/consent-prompt.js
index 7ed5398..7275a55 100644
--- a/Extensions/combined/src/consent-prompt.js
+++ b/Extensions/combined/src/consent-prompt.js
@@ -1,5 +1,6 @@
import { consentApi, hasCoreDataPermission, onCoreDataPermissionChanged } from "./core-data-consent";
import { usesFirefoxDataCollectionConsent } from "./data-collection-permissions";
+import { isPrivateBrowsingContext } from "./private-browsing";
let pendingConsent = null;
@@ -10,9 +11,11 @@ function waitForCoreDataConsent({ alwaysExplain = false } = {}) {
if (await hasCoreDataPermission()) return;
const api = consentApi();
let dismissed = false;
- try {
- dismissed = (await api.storage.local.get("rydConsentDismissed"))?.rydConsentDismissed === true;
- } catch (_) {}
+ if (!isPrivateBrowsingContext()) {
+ try {
+ dismissed = (await api.storage.local.get("rydConsentDismissed"))?.rydConsentDismissed === true;
+ } catch (_) {}
+ }
await new Promise((resolve) => {
const host = document.createElement("div");
host.id = "ryd-consent-prompt";
diff --git a/Extensions/combined/src/consent-prompt.spec.js b/Extensions/combined/src/consent-prompt.spec.js
index 4162366..566f28e 100644
--- a/Extensions/combined/src/consent-prompt.spec.js
+++ b/Extensions/combined/src/consent-prompt.spec.js
@@ -5,6 +5,7 @@ jest.mock("./core-data-consent", () => ({
onCoreDataPermissionChanged: jest.fn(),
}));
jest.mock("./data-collection-permissions", () => ({ usesFirefoxDataCollectionConsent: jest.fn() }));
+jest.mock("./private-browsing", () => ({ isPrivateBrowsingContext: jest.fn() }));
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
describe("consent overlay lifecycle", () => {
@@ -16,7 +17,8 @@ describe("consent overlay lifecycle", () => {
jest.resetModules();
core = require("./core-data-consent");
require("./data-collection-permissions").usesFirefoxDataCollectionConsent.mockReturnValue(true);
- local = { get: jest.fn(async () => ({})) };
+ require("./private-browsing").isPrivateBrowsingContext.mockReturnValue(false);
+ local = { get: jest.fn(async () => ({})), set: jest.fn(), remove: jest.fn() };
core.consentApi.mockReturnValue({
storage: { local },
runtime: { getURL: () => "moz-extension://fixture/consent.html" },
@@ -96,4 +98,43 @@ describe("consent overlay lifecycle", () => {
changed(true);
await pending;
});
+
+ test("private setup skips stored dismissal state and dismisses only the current overlay", async () => {
+ require("./private-browsing").isPrivateBrowsingContext.mockReturnValue(true);
+ local.get.mockResolvedValue({ rydConsentDismissed: true });
+ const pending = waitForConsent();
+ let granted = false;
+ void pending.then(() => (granted = true));
+ const { frame, host } = await mounted();
+ expect(host.hasAttribute("collapsed")).toBe(false);
+ expect(local.get).not.toHaveBeenCalled();
+ message(frame, "ryd-consent-dismissed");
+ await flush();
+ expect(host.hasAttribute("collapsed")).toBe(true);
+ expect(granted).toBe(false);
+ host.shadowRoot.querySelector("button").click();
+ expect(host.hasAttribute("collapsed")).toBe(false);
+ expect(local.set).not.toHaveBeenCalled();
+ expect(local.remove).not.toHaveBeenCalled();
+ core.hasCoreDataPermission.mockResolvedValue(true);
+ changed(true);
+ await pending;
+ expect(host.isConnected).toBe(false);
+ });
+
+ test.each([false, true])(
+ "an existing grant skips setup and stored dismissal state (private=%s)",
+ async (privateBrowsing) => {
+ require("./private-browsing").isPrivateBrowsingContext.mockReturnValue(privateBrowsing);
+ local.get.mockResolvedValue({ rydConsentDismissed: true });
+ core.hasCoreDataPermission.mockResolvedValue(true);
+ await waitForConsent();
+ expect(document.querySelector("#ryd-consent-prompt")).toBeNull();
+ expect(core.consentApi).not.toHaveBeenCalled();
+ expect(core.onCoreDataPermissionChanged).not.toHaveBeenCalled();
+ expect(local.get).not.toHaveBeenCalled();
+ expect(local.set).not.toHaveBeenCalled();
+ expect(local.remove).not.toHaveBeenCalled();
+ },
+ );
});
diff --git a/Extensions/combined/src/content-script-private.spec.js b/Extensions/combined/src/content-script-private.spec.js
new file mode 100644
index 0000000..37d139c
--- /dev/null
+++ b/Extensions/combined/src/content-script-private.spec.js
@@ -0,0 +1,199 @@
+/** @jest-environment jsdom */
+
+import { readFileSync } from "fs";
+import { join } from "path";
+import { transformSync } from "@babel/core";
+import * as initializationCycle from "./initialization-cycle";
+
+const source = readFileSync(join(__dirname, "../ryd.content-script.js"), "utf8");
+const executable = transformSync(source, {
+ babelrc: false,
+ configFile: false,
+ plugins: [
+ ({ types }) => ({
+ visitor: {
+ ImportDeclaration(path) {
+ const bindings = path.node.specifiers.map(({ imported, local }) =>
+ types.objectProperty(imported, local, false, imported.name === local.name),
+ );
+ path.replaceWith(
+ types.variableDeclaration("const", [
+ types.variableDeclarator(
+ types.objectPattern(bindings),
+ types.memberExpression(types.identifier("dependencies"), path.node.source, true),
+ ),
+ ]),
+ );
+ },
+ },
+ }),
+ ],
+}).code;
+const execute = new Function(
+ "dependencies",
+ "window",
+ "document",
+ "setInterval",
+ "clearInterval",
+ "setTimeout",
+ "clearTimeout",
+ "__RYD_LIVE_TEST_BUILD__",
+ `return (async () => { ${executable} })();`,
+);
+
+function harness(privateContext) {
+ const page = document.implementation.createHTMLDocument();
+ const pageWindow = new EventTarget();
+ pageWindow.location = new URL("https://www.youtube.com/watch?v=abcdefghijk");
+ const buttons = page.createElement("div");
+ const likeButton = page.createElement("div");
+ const dislikeButton = page.createElement("div");
+ const nativeLikeButton = page.createElement("button");
+ const nativeDislikeButton = page.createElement("button");
+ likeButton.append(nativeLikeButton);
+ dislikeButton.append(nativeDislikeButton);
+ buttons.append(likeButton, dislikeButton);
+ page.body.append(buttons);
+ const controls = {
+ buttons,
+ dislikeButton,
+ dislikeTextContainer: page.createElement("span"),
+ likeButton,
+ nativeDislikeButton,
+ nativeLikeButton,
+ ready: true,
+ };
+ const storedData = { likes: 0, dislikes: 0, videoId: null, previousState: "NEUTRAL_STATE" };
+ const storage = { onChanged: { addListener: jest.fn() } };
+ const state = {
+ NEUTRAL_STATE: "NEUTRAL_STATE",
+ storedData,
+ clearRenderedVoteState: jest.fn(),
+ hasLoadedStateForVideo: (videoId) => storedData.videoId === videoId,
+ initExtConfig: jest.fn(async () => undefined),
+ isLikesDisabled: () => false,
+ isShorts: () => false,
+ restoreCurrentState: jest.fn(() => true),
+ setInitialState: jest.fn(async () => {
+ Object.assign(storedData, { likes: 100, dislikes: 10, videoId: "abcdefghijk" });
+ return true;
+ }),
+ };
+ let permissionChanged;
+ const buttonFunctions = {
+ getButtonControls: () => controls,
+ getDislikeTextContainer: () => controls.dislikeTextContainer,
+ hasRenderedBox: () => true,
+ markButtonsForVideo: jest.fn(),
+ setSyntheticShortsDislikeEnabled: jest.fn(),
+ };
+ const dependencies = {
+ "./src/buttons": buttonFunctions,
+ "./src/state": state,
+ "./src/utils": {
+ getBrowser: () => ({ storage, runtime: { getURL: (path) => path } }),
+ getVideoId: () => "abcdefghijk",
+ isVideoLoaded: () => true,
+ },
+ "./src/events": {
+ addLikeDislikeEventListener: jest.fn(),
+ createSmartimationObserver: jest.fn(),
+ storageChangeHandler: jest.fn(),
+ observeColorTheme: jest.fn(),
+ },
+ "./src/bar": { hasUsableRateBar: () => true },
+ "./src/initialization-cycle": initializationCycle,
+ "./src/patreon": { initPatreonFeatures: jest.fn() },
+ "./src/discussion": { initDiscussion: jest.fn() },
+ "./src/consent-prompt": { waitForCoreDataConsent: jest.fn(async () => true) },
+ "./src/core-data-consent": {
+ onCoreDataPermissionChanged: (listener) => {
+ permissionChanged = listener;
+ },
+ },
+ "./src/vote-data-request": {
+ cancelObsoleteVoteDataRequests: jest.fn(),
+ clearVoteDataRequestCache: jest.fn(),
+ },
+ "./src/private-browsing": { isPrivateBrowsingContext: () => privateContext },
+ "../common/shorts-control-readiness": {},
+ };
+ const append = page.head.appendChild.bind(page.head);
+ page.head.appendChild = (script) => {
+ append(script);
+ script.onload.call(script);
+ return script;
+ };
+ return {
+ buttonFunctions,
+ dependencies,
+ page,
+ pageWindow,
+ permissionChanged: (granted) => permissionChanged(granted),
+ run: () => execute(dependencies, pageWindow, page, setInterval, clearInterval, setTimeout, clearTimeout, false),
+ state,
+ storage,
+ };
+}
+
+const flush = async () => {
+ for (let index = 0; index < 20; index++) await Promise.resolve();
+};
+
+beforeEach(() => jest.useFakeTimers());
+afterEach(() => {
+ jest.clearAllTimers();
+ jest.useRealTimers();
+});
+
+test.each([false, true])(
+ "initializes count display with context-specific account and storage setup (private=%s)",
+ async (privateContext) => {
+ const context = harness(privateContext);
+ await context.run();
+ expect(context.state.initExtConfig).toHaveBeenCalledTimes(1);
+ expect(context.state.setInitialState).toHaveBeenCalledTimes(1);
+ expect(context.buttonFunctions.markButtonsForVideo).toHaveBeenCalledTimes(1);
+ expect(context.storage.onChanged.addListener).toHaveBeenCalledTimes(privateContext ? 0 : 1);
+ expect(context.dependencies["./src/patreon"].initPatreonFeatures).toHaveBeenCalledTimes(privateContext ? 0 : 1);
+ expect(context.dependencies["./src/discussion"].initDiscussion).toHaveBeenCalledTimes(privateContext ? 0 : 1);
+ },
+);
+
+test.each(["pagehide", "yt-navigate-start", "consent-loss"])(
+ "clears private document count state on %s",
+ async (eventName) => {
+ const context = harness(true);
+ await context.run();
+ const requests = context.dependencies["./src/vote-data-request"];
+ requests.clearVoteDataRequestCache.mockClear();
+ if (eventName === "consent-loss") context.permissionChanged(false);
+ else (eventName === "pagehide" ? context.pageWindow : context.page).dispatchEvent(new Event(eventName));
+ expect(requests.clearVoteDataRequestCache).toHaveBeenCalled();
+ expect(context.state.storedData).toEqual({ likes: 0, dislikes: 0, previousState: "NEUTRAL_STATE", videoId: null });
+ expect(context.state.clearRenderedVoteState).toHaveBeenCalled();
+ },
+);
+
+test("private page exit suppresses late initialization and requests a fresh state on return", async () => {
+ const context = harness(true);
+ let resolveInitialState;
+ context.state.setInitialState.mockImplementationOnce(() => new Promise((resolve) => (resolveInitialState = resolve)));
+ const initialized = context.run();
+ await flush();
+ expect(context.state.setInitialState).toHaveBeenCalledTimes(1);
+ context.pageWindow.dispatchEvent(new Event("pagehide"));
+ resolveInitialState(true);
+ await initialized;
+ jest.advanceTimersByTime(1000);
+ await flush();
+ expect(context.buttonFunctions.markButtonsForVideo).not.toHaveBeenCalled();
+ expect(context.buttonFunctions.setSyntheticShortsDislikeEnabled).not.toHaveBeenCalled();
+ expect(context.state.setInitialState).toHaveBeenCalledTimes(1);
+
+ context.pageWindow.dispatchEvent(new Event("pageshow"));
+ jest.advanceTimersByTime(500);
+ await flush();
+ expect(context.state.setInitialState).toHaveBeenCalledTimes(2);
+ expect(context.buttonFunctions.markButtonsForVideo).toHaveBeenCalledTimes(1);
+});
diff --git a/Extensions/combined/src/core-data-consent.js b/Extensions/combined/src/core-data-consent.js
index 23d0241..d76a353 100644
--- a/Extensions/combined/src/core-data-consent.js
+++ b/Extensions/combined/src/core-data-consent.js
@@ -1,4 +1,6 @@
import { usesFirefoxDataCollectionConsent } from "./data-collection-permissions";
+import { getApiUrl } from "./config";
+import { isPrivateBrowsingContext } from "./private-browsing";
const CORE_DATA_PERMISSIONS = Object.freeze([
"personallyIdentifyingInfo",
@@ -32,6 +34,7 @@ async function hasCoreDataPermission({ queryBackground = true } = {}) {
}
function requestCoreDataPermission() {
+ if (isPrivateBrowsingContext()) return Promise.resolve(false);
if (!usesFirefoxDataCollectionConsent()) return Promise.resolve(true);
try {
// Request directly in the explainer's click handler to preserve Firefox's user gesture.
@@ -72,6 +75,37 @@ function onCoreDataPermissionChanged(listener) {
function fetchWithCoreConsent(input, options = {}, fetchImpl = globalThis.fetch) {
if (!usesFirefoxDataCollectionConsent()) return fetchImpl(input, options);
return (async () => {
+ if (isPrivateBrowsingContext()) {
+ const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url);
+ const method = options.method ?? input?.method ?? "GET";
+ const allowedKeys = url.pathname === "/votes" ? ["videoId", "likeCount"] : [];
+ let allowedQuery = true;
+ // Firefox content-script wrappers do not expose URLSearchParams iterators.
+ url.searchParams.forEach((value, key) => {
+ if (!allowedKeys.includes(key)) allowedQuery = false;
+ });
+ if (
+ url.origin !== new URL(getApiUrl()).origin ||
+ !["/votes", "/configs/selectors"].includes(url.pathname) ||
+ method.toUpperCase() !== "GET" ||
+ url.username ||
+ url.password ||
+ !allowedQuery ||
+ options.body != null ||
+ input?.body != null
+ )
+ throw new Error("Only count display is available in private windows.");
+ input = url.href;
+ options = {
+ method: "GET",
+ headers: { Accept: "application/json" },
+ credentials: "omit",
+ cache: "no-store",
+ redirect: "error",
+ referrerPolicy: "no-referrer",
+ signal: options.signal,
+ };
+ }
const controller = new AbortController();
const abort = () => controller.abort();
const unsubscribe = onCoreDataPermissionChanged((granted) => {
diff --git a/Extensions/combined/src/core-data-consent.spec.js b/Extensions/combined/src/core-data-consent.spec.js
index 54be343..361d720 100644
--- a/Extensions/combined/src/core-data-consent.spec.js
+++ b/Extensions/combined/src/core-data-consent.spec.js
@@ -135,6 +135,75 @@ describe("optional Firefox core data consent", () => {
expect(browser.runtime.onMessage.size()).toBe(0);
});
+ test("private counts strip credentials and headers and disable caching and redirects", async () => {
+ granted = [...CORE_DATA_PERMISSIONS];
+ browser.extension = { inIncognitoContext: true };
+ const fetchImpl = jest.fn(async () => new Response('{"dislikes":42}'));
+ for (const path of ["/votes?videoId=abcdefghijk&likeCount=100", "/configs/selectors"]) {
+ const result = await fetchWithCoreConsent(
+ `https://returnyoutubedislikeapi.com${path}`,
+ { credentials: "include", cache: "force-cache", headers: { Authorization: "synthetic-account-token" } },
+ fetchImpl,
+ );
+ expect(await result.json()).toEqual({ dislikes: 42 });
+ expect(fetchImpl.mock.calls.at(-1)[1]).toEqual(
+ expect.objectContaining({
+ method: "GET",
+ credentials: "omit",
+ cache: "no-store",
+ redirect: "error",
+ referrerPolicy: "no-referrer",
+ headers: { Accept: "application/json" },
+ }),
+ );
+ }
+ expect(await requestCoreDataPermission()).toBe(false);
+ expect(browser.permissions.request).not.toHaveBeenCalled();
+ });
+
+ test("private count checks work when Firefox blocks URLSearchParams iterators", async () => {
+ granted = [...CORE_DATA_PERMISSIONS];
+ browser.extension = { inIncognitoContext: true };
+ const keys = jest.spyOn(URLSearchParams.prototype, "keys").mockReturnValue({
+ [Symbol.iterator]() {
+ throw new TypeError("Iterator access denied");
+ },
+ });
+ try {
+ const fetchImpl = jest.fn(async () => new Response('{"dislikes":42}'));
+ const response = await fetchWithCoreConsent(
+ "https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk&likeCount=100",
+ {},
+ fetchImpl,
+ );
+ expect(await response.json()).toEqual({ dislikes: 42 });
+ await expect(
+ fetchWithCoreConsent(
+ "https://returnyoutubedislikeapi.com/votes?videoId=abcdefghijk&sessionToken=synthetic",
+ {},
+ fetchImpl,
+ ),
+ ).rejects.toThrow("count display");
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
+ } finally {
+ keys.mockRestore();
+ }
+ });
+
+ test.each([
+ ["https://returnyoutubedislikeapi.com/send", {}],
+ ["https://returnyoutubedislikeapi.com/votes", { method: "POST" }],
+ ["https://returnyoutubedislikeapi.com/votes?sessionToken=synthetic", {}],
+ ["https://returnyoutubedislikeapi.com/configs/selectors?videoId=abcdefghijk", {}],
+ ["https://api.example/votes", {}],
+ ])("private requests reject non-display operations: %s", async (url, options) => {
+ granted = [...CORE_DATA_PERMISSIONS];
+ browser.extension = { inIncognitoContext: true };
+ const fetchImpl = jest.fn();
+ await expect(fetchWithCoreConsent(url, options, fetchImpl)).rejects.toThrow("count display");
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+
test("other browsers keep their normal fetch and do not request Firefox permissions", async () => {
browser.runtime.getManifest = () => ({ manifest_version: 3 });
const response = {};
diff --git a/Extensions/combined/src/data-collection-permissions.js b/Extensions/combined/src/data-collection-permissions.js
index ca217ec..cec6d62 100644
--- a/Extensions/combined/src/data-collection-permissions.js
+++ b/Extensions/combined/src/data-collection-permissions.js
@@ -1,3 +1,5 @@
+import { isPrivateBrowsingContext } from "./private-browsing";
+
const AUTHENTICATION_DATA_PERMISSION = "authenticationInfo";
const ACCOUNT_DATA_PERMISSIONS = Object.freeze([AUTHENTICATION_DATA_PERMISSION]);
const AUTHENTICATION_DATA_DESCRIPTOR = Object.freeze({
@@ -51,6 +53,7 @@ function queryBackgroundForAuthenticationDataPermission() {
}
function hasAuthenticationDataPermission({ queryBackground = true } = {}) {
+ if (isPrivateBrowsingContext()) return Promise.resolve(false);
if (!usesFirefoxDataCollectionConsent()) return Promise.resolve(true);
const permissions = getPermissionsApi();
if (!permissions) {
@@ -71,6 +74,7 @@ function hasAuthenticationDataPermission({ queryBackground = true } = {}) {
}
function requestAuthenticationDataPermission() {
+ if (isPrivateBrowsingContext()) return Promise.resolve(false);
if (!usesFirefoxDataCollectionConsent()) return Promise.resolve(true);
// Keep this direct request as the first operation in the login click stack.
diff --git a/Extensions/combined/src/data-collection-permissions.spec.js b/Extensions/combined/src/data-collection-permissions.spec.js
index ddb791a..46158f1 100644
--- a/Extensions/combined/src/data-collection-permissions.spec.js
+++ b/Extensions/combined/src/data-collection-permissions.spec.js
@@ -56,6 +56,21 @@ describe("Firefox data-collection permissions", () => {
expect(request).not.toHaveBeenCalled();
});
+ it("private windows cannot query or request account consent", async () => {
+ global.browser = {
+ extension: { inIncognitoContext: true },
+ runtime: { getManifest: () => firefoxManifest, sendMessage: jest.fn() },
+ permissions: { request: jest.fn(), getAll: jest.fn() },
+ };
+ await expect(hasAuthenticationDataPermission()).resolves.toBe(false);
+ await expect(requestAuthenticationDataPermission()).resolves.toBe(false);
+ expect(browser.permissions.getAll).not.toHaveBeenCalled();
+ expect(browser.permissions.request).not.toHaveBeenCalled();
+ delete browser.permissions;
+ await expect(hasAuthenticationDataPermission()).resolves.toBe(false);
+ expect(browser.runtime.sendMessage).not.toHaveBeenCalled();
+ });
+
it.each([{}, { data_collection: [] }, { data_collection: ["financialAndPaymentInfo"] }])(
"does not accept a successful request without a recorded authentication grant: %j",
async (grantedPermissions) => {
diff --git a/Extensions/combined/src/discussion-background.js b/Extensions/combined/src/discussion-background.js
index 12bfac6..20c054a 100644
--- a/Extensions/combined/src/discussion-background.js
+++ b/Extensions/combined/src/discussion-background.js
@@ -1,6 +1,7 @@
import { HubConnectionBuilder, HttpTransportType, LogLevel } from "@microsoft/signalr";
import { createVoteClient, solvePuzzle } from "../../common/vote-client";
import { discussionConfig } from "./discussion-config";
+import { isFirefoxExtension, isNormalMessageSender } from "./private-browsing";
const ANONYMOUS_SESSION = Object.freeze({
authenticated: false,
@@ -205,6 +206,7 @@ export function createDiscussionBackground({
api,
voteClient,
fetchImpl = (...args) => fetch(...args),
+ accountFetchImpl = fetchImpl,
hasConsent = async () => true,
settings = discussionConfig,
connectionFactory = createConnection,
@@ -303,7 +305,7 @@ export function createDiscussionBackground({
scope?.controllers.add(controller);
const timeout = setTimeout(() => controller.abort(), 20000);
try {
- const response = await fetchImpl(`${origin}/api/discussion${path}`, {
+ const response = await (auth ? accountFetchImpl : fetchImpl)(`${origin}/api/discussion${path}`, {
method,
credentials: "omit",
cache: "no-store",
@@ -359,22 +361,58 @@ export function createDiscussionBackground({
type === "toast" ? { active: true, lastFocusedWindow: true } : {},
);
if (expected !== generation || stopped) return;
- const targets = (tabs || []).filter((tab) => isYouTubePage(tab.url));
+ const firefox = isFirefoxExtension(api);
+ const targets = (tabs || []).filter((tab) => isYouTubePage(tab.url) && (!firefox || tab.incognito === false));
await Promise.allSettled(
(type === "toast" ? targets.slice(0, 1) : targets).map((tab) =>
callBrowser(api, api.tabs, "sendMessage", tab.id, message),
),
);
if (type !== "toast" && expected === generation) {
- await callBrowser(api, api.runtime, "sendMessage", message).catch(() => {});
+ if (firefox) {
+ try {
+ const extensionUrl = api.runtime.getURL("");
+ for (const view of api.extension?.getViews?.() || []) {
+ try {
+ if (
+ view.browser?.extension?.inIncognitoContext === false &&
+ view.location?.href?.startsWith(extensionUrl)
+ )
+ view.dispatchEvent(new view.CustomEvent("ryd-discussion-event", { detail: structuredClone(message) }));
+ } catch (_) {}
+ }
+ } catch (_) {}
+ } else {
+ await callBrowser(api, api.runtime, "sendMessage", message).catch(() => {});
+ }
}
}
+ function badgeText() {
+ return inbox.unreadCount ? String(Math.min(inbox.unreadCount, 99)) + (inbox.unreadCount > 99 ? "+" : "") : "";
+ }
+
+ async function updateTabBadge(tab, expected = generation) {
+ if (expected !== generation || stopped || tab?.incognito !== false || !Number.isInteger(tab.id)) return;
+ const action = api.action || api.browserAction;
+ await callBrowser(api, action, "setBadgeText", { tabId: tab.id, text: badgeText() }).catch(() => {});
+ if (expected === generation && !stopped)
+ await callBrowser(api, action, "setBadgeBackgroundColor", { tabId: tab.id, color: "#cc0000" }).catch(() => {});
+ }
+
async function updateBadge(expected = generation) {
if (expected !== generation || stopped) return;
const action = api.action || api.browserAction;
+ if (isFirefoxExtension(api)) {
+ await callBrowser(api, action, "setBadgeText", { text: "" }).catch(() => {});
+ if (expected !== generation || stopped) return;
+ const tabs = await callBrowser(api, api.tabs, "query", {});
+ if (expected !== generation || stopped) return;
+ await Promise.allSettled((tabs || []).map((tab) => updateTabBadge(tab, expected)));
+ return;
+ }
await callBrowser(api, action, "setBadgeText", {
- text: inbox.unreadCount ? String(Math.min(inbox.unreadCount, 99)) + (inbox.unreadCount > 99 ? "+" : "") : "",
+ text: badgeText(),
}).catch(() => {});
if (expected === generation)
await callBrowser(api, action, "setBadgeBackgroundColor", { color: "#cc0000" }).catch(() => {});
@@ -706,6 +744,7 @@ export function createDiscussionBackground({
async function handleMessage(requestMessage, sender) {
try {
+ if (!isNormalMessageSender(api, sender)) throw new DiscussionError("private_browsing");
const { operation, payload, scope: scopeValue } = validateRequest(api, requestMessage, sender);
const scope = scopeFor(scopeValue, sender);
if (operation === "cancelScope") {
@@ -745,6 +784,11 @@ export function createDiscussionBackground({
}
function initialize() {
+ if (isFirefoxExtension(api)) {
+ api.tabs?.onCreated?.addListener((tab) => updateTabBadge(tab).catch(() => {}));
+ api.tabs?.onUpdated?.addListener((tabId, change, tab) => updateTabBadge(tab).catch(() => {}));
+ updateBadge().catch(() => {});
+ }
api.tabs?.onRemoved?.addListener((tabId) => {
for (const [key, scope] of navigationScopes)
if (scope.tabId === tabId) {
diff --git a/Extensions/combined/src/discussion-background.spec.js b/Extensions/combined/src/discussion-background.spec.js
index 9bb1999..df5dd21 100644
--- a/Extensions/combined/src/discussion-background.spec.js
+++ b/Extensions/combined/src/discussion-background.spec.js
@@ -32,6 +32,17 @@ const deferred = () => {
return { promise, resolve };
};
+function extensionView(incognito, url = "moz-extension://extension-id/popup.html") {
+ return {
+ browser: { extension: { inIncognitoContext: incognito } },
+ location: { href: url },
+ CustomEvent: jest.fn(function (type, options) {
+ return { type, detail: options.detail };
+ }),
+ dispatchEvent: jest.fn(),
+ };
+}
+
function storageArea(data = {}) {
return {
data,
@@ -47,16 +58,27 @@ function storageArea(data = {}) {
};
}
-function harness({ authenticated = false, consent = true, fetchOverride, testMode = false, solveOverride } = {}) {
+function harness({
+ authenticated = false,
+ consent = true,
+ fetchOverride,
+ accountFetchImpl,
+ testMode = false,
+ solveOverride,
+ firefox = false,
+ views = [],
+} = {}) {
const sync = storageArea(authenticated ? { patreonAuthenticated: true, patreonSessionToken: "account-token" } : {});
const local = storageArea();
let alarmListener;
const api = {
runtime: {
id: "extension-id",
- getURL: (path) => `chrome-extension://extension-id/${path}`,
+ getURL: (path) => `${firefox ? "moz" : "chrome"}-extension://extension-id/${path}`,
+ getManifest: () => (firefox ? { browser_specific_settings: { gecko: {} } } : {}),
sendMessage: jest.fn((message, callback) => callback?.()),
},
+ extension: { getViews: jest.fn(() => views) },
storage: { sync, local },
action: {
setBadgeText: jest.fn((value, callback) => callback?.()),
@@ -65,12 +87,14 @@ function harness({ authenticated = false, consent = true, fetchOverride, testMod
tabs: {
query: jest.fn((query, callback) =>
callback([
- { id: 1, url: sender.url },
- { id: 2, url: sender.url },
- { id: 3, url: "https://example.com/" },
+ { id: 1, url: sender.url, incognito: false },
+ { id: 2, url: sender.url, incognito: false },
+ { id: 3, url: "https://example.com/", incognito: false },
]),
),
sendMessage: jest.fn((id, message, callback) => callback?.()),
+ onCreated: { addListener: jest.fn() },
+ onUpdated: { addListener: jest.fn() },
},
alarms: {
create: jest.fn(),
@@ -168,6 +192,7 @@ function harness({ authenticated = false, consent = true, fetchOverride, testMod
api,
voteClient,
fetchImpl,
+ accountFetchImpl,
hasConsent: async () => consent,
connectionFactory,
solve: solveOverride || jest.fn(async () => ({ solution: "AAAAAA==" })),
@@ -206,7 +231,242 @@ function harness({ authenticated = false, consent = true, fetchOverride, testMod
};
}
+describe("Firefox private discussion isolation", () => {
+ const normalSender = { ...sender, tab: { ...sender.tab, incognito: false } };
+
+ test.each(["session", "capabilities", "comments", "react", "notifications", "cancelScope"])(
+ "rejects private %s before validation, account reads, or shared work",
+ async (operation) => {
+ const h = harness({ authenticated: true, firefox: true });
+ const source = { ...normalSender, tab: { ...normalSender.tab, incognito: true } };
+ expect(await h.send(operation, { invalid: true }, source)).toMatchObject({
+ ok: false,
+ error: { code: "private_browsing" },
+ });
+ expect(h.sync.get).not.toHaveBeenCalled();
+ expect(h.local.get).not.toHaveBeenCalled();
+ expect(h.fetchImpl).not.toHaveBeenCalled();
+ expect(h.voteClient.getRegisteredUser).not.toHaveBeenCalled();
+ expect(h.voteClient.ensureRegistered).not.toHaveBeenCalled();
+ expect(h.api.tabs.sendMessage).not.toHaveBeenCalled();
+ expect(h.api.action.setBadgeText).not.toHaveBeenCalled();
+ },
+ );
+
+ test("rejects a tab whose normal browsing context cannot be verified", async () => {
+ const h = harness({ authenticated: true, firefox: true });
+ expect(await h.send("session", {}, sender)).toMatchObject({ error: { code: "private_browsing" } });
+ expect(h.sync.get).not.toHaveBeenCalled();
+ expect(h.fetchImpl).not.toHaveBeenCalled();
+ });
+
+ test.each([true, undefined])(
+ "rejects a private or unverified extension view (%s) before account reads",
+ async (incognito) => {
+ const url = "moz-extension://extension-id/popup.html?rydContext=private-test-context";
+ const h = harness({ authenticated: true, firefox: true, views: [extensionView(incognito, url)] });
+ expect(await h.send("session", {}, { id: sender.id, url })).toMatchObject({
+ ok: false,
+ error: { code: "private_browsing" },
+ });
+ expect(h.sync.get).not.toHaveBeenCalled();
+ expect(h.fetchImpl).not.toHaveBeenCalled();
+ },
+ );
+
+ test("preserves account requests from a verified normal Firefox extension view", async () => {
+ const url = "moz-extension://extension-id/popup.html?rydContext=normal-test-context";
+ const h = harness({ authenticated: true, firefox: true, views: [extensionView(false, url)] });
+ expect((await h.send("session", {}, { id: sender.id, url })).data.authenticated).toBe(true);
+ h.client.stop();
+ });
+
+ test("delivers shared account events only to normal YouTube tabs and verified extension views", async () => {
+ const normal = extensionView(false);
+ const privateView = extensionView(true);
+ const unknown = extensionView(undefined);
+ const foreign = extensionView(false, "https://www.youtube.com/watch?v=video123456");
+ const unavailable = extensionView(false);
+ unavailable.dispatchEvent.mockImplementation(() => {
+ throw new Error("View closed");
+ });
+ const h = harness({
+ authenticated: true,
+ firefox: true,
+ views: [privateView, unknown, foreign, unavailable, normal],
+ });
+ h.api.tabs.query.mockImplementation((query, done) =>
+ done([
+ { id: 2, url: sender.url, incognito: true },
+ { id: 1, url: sender.url, incognito: false },
+ { id: 3, url: sender.url },
+ ]),
+ );
+ expect((await h.send("session", {}, normalSender)).data.authenticated).toBe(true);
+ await flush();
+ expect(h.api.tabs.sendMessage.mock.calls.length).toBeGreaterThan(0);
+ expect(h.api.tabs.sendMessage.mock.calls.every(([tabId]) => tabId === 1)).toBe(true);
+ expect(
+ normal.dispatchEvent.mock.calls.some(
+ ([event]) => event.detail.type === "session" && event.detail.data.authenticated,
+ ),
+ ).toBe(true);
+ expect(
+ normal.dispatchEvent.mock.calls.some(
+ ([event]) => event.detail.type === "inbox" && event.detail.data.unreadCount === 1,
+ ),
+ ).toBe(true);
+ expect(normal.CustomEvent).toHaveBeenCalledWith(
+ "ryd-discussion-event",
+ expect.objectContaining({ detail: expect.any(Object) }),
+ );
+ for (const view of [privateView, unknown, foreign]) {
+ expect(view.CustomEvent).not.toHaveBeenCalled();
+ expect(view.dispatchEvent).not.toHaveBeenCalled();
+ }
+ expect(h.api.runtime.sendMessage).not.toHaveBeenCalled();
+ h.client.stop();
+ });
+
+ test("isolates each Firefox view's session and inbox mutations from the worker and other views", async () => {
+ const first = extensionView(false);
+ const second = extensionView(false);
+ first.dispatchEvent.mockImplementation(({ detail }) => {
+ if (detail.type === "session" && detail.data.profile) detail.data.profile.nickname = "Changed in one view";
+ if (detail.type === "inbox" && detail.data.items.length) {
+ detail.data.items[0].read = true;
+ detail.data.items[0].actorNickname = "Changed in one view";
+ }
+ });
+ const h = harness({ authenticated: true, firefox: true, views: [first, second] });
+ const response = await h.send("session", {}, normalSender);
+ await flush();
+ expect(response.data.profile.nickname).toBe("Tester");
+ expect((await h.send("session", {}, normalSender)).data.profile.nickname).toBe("Tester");
+ const sessionEvent = second.dispatchEvent.mock.calls.find(
+ ([event]) => event.detail.type === "session" && event.detail.data.authenticated,
+ )[0];
+ expect(sessionEvent.detail.data.profile.nickname).toBe("Tester");
+ const inboxEvent = second.dispatchEvent.mock.calls.find(
+ ([event]) => event.detail.type === "inbox" && event.detail.data.items.length,
+ )[0];
+ expect(inboxEvent.detail.data.items[0]).toMatchObject({ read: false, actorNickname: null });
+ expect(h.local.data.discussionState.items[0]).toMatchObject({ read: false, actorNickname: null });
+ h.client.stop();
+ });
+
+ test("never falls back to global runtime account events when extension views are unavailable", async () => {
+ const h = harness({ authenticated: true, firefox: true });
+ h.api.extension.getViews.mockImplementation(() => {
+ throw new Error("Unavailable");
+ });
+ expect((await h.send("session", {}, normalSender)).data.authenticated).toBe(true);
+ await flush();
+ expect(h.api.runtime.sendMessage).not.toHaveBeenCalled();
+ expect(h.api.tabs.sendMessage.mock.calls.some(([, event]) => event.type === "inbox")).toBe(true);
+ h.client.stop();
+ });
+
+ test("does not send account toasts to an active private YouTube tab", async () => {
+ const h = harness({ authenticated: true, firefox: true });
+ h.api.tabs.query.mockImplementation((query, done) =>
+ done(
+ query.active ? [{ id: 2, url: sender.url, incognito: true }] : [{ id: 1, url: sender.url, incognito: false }],
+ ),
+ );
+ await h.send("session", {}, normalSender);
+ await flush();
+ h.setInbox({
+ items: [{ ...h.notification, revision: "2", likeCount: 2 }],
+ unreadCount: 1,
+ revision: "2",
+ nextCursor: null,
+ });
+ h.sockets[0].handlers.NotificationsChanged({ revision: "2" });
+ await flush();
+ await flush();
+ expect(h.api.tabs.sendMessage.mock.calls.some(([, event]) => event.type === "toast")).toBe(false);
+ expect(h.api.tabs.sendMessage.mock.calls.some(([tabId]) => tabId === 2)).toBe(false);
+ h.client.stop();
+ });
+
+ test("keeps the default badge empty and applies account counts only to normal tabs, including new tabs", async () => {
+ const h = harness({ authenticated: true, firefox: true });
+ h.api.tabs.query.mockImplementation((query, done) =>
+ done([
+ { id: 1, url: sender.url, incognito: false },
+ { id: 2, url: sender.url, incognito: true },
+ { id: 3, url: "https://example.com/", incognito: false },
+ { id: 4, url: sender.url },
+ ]),
+ );
+ h.client.initialize();
+ await flush();
+ await flush();
+ const badgeValues = h.api.action.setBadgeText.mock.calls.map(([value]) => value);
+ expect(badgeValues).toContainEqual({ text: "" });
+ expect(badgeValues).toContainEqual({ tabId: 1, text: "1" });
+ expect(badgeValues).toContainEqual({ tabId: 3, text: "1" });
+ expect(
+ badgeValues.every((value) => (value.tabId === undefined ? value.text === "" : [1, 3].includes(value.tabId))),
+ ).toBe(true);
+ expect(h.api.action.setBadgeBackgroundColor.mock.calls.every(([value]) => [1, 3].includes(value.tabId))).toBe(true);
+
+ h.api.action.setBadgeText.mockClear();
+ h.api.action.setBadgeBackgroundColor.mockClear();
+ const created = h.api.tabs.onCreated.addListener.mock.calls[0][0];
+ const updated = h.api.tabs.onUpdated.addListener.mock.calls[0][0];
+ await created({ id: 5, incognito: true });
+ await updated(5, {}, { id: 5, incognito: true });
+ await created({ id: 6 });
+ expect(h.api.action.setBadgeText).not.toHaveBeenCalled();
+ expect(h.api.action.setBadgeBackgroundColor).not.toHaveBeenCalled();
+ await created({ id: 7, incognito: false });
+ await updated(8, {}, { id: 8, incognito: false });
+ expect(h.api.action.setBadgeText.mock.calls.map(([value]) => value)).toEqual([
+ { tabId: 7, text: "1" },
+ { tabId: 8, text: "1" },
+ ]);
+ h.client.clearSession();
+ await flush();
+ expect(h.api.action.setBadgeText).toHaveBeenCalledWith({ tabId: 1, text: "" }, expect.any(Function));
+ h.client.stop();
+ });
+
+ test("preserves non-Firefox incognito discussion requests and global account updates", async () => {
+ const h = harness({ authenticated: true });
+ const response = await h.send("session", {}, { ...sender, tab: { ...sender.tab, incognito: true } });
+ expect(response.data.authenticated).toBe(true);
+ await flush();
+ expect(
+ h.api.runtime.sendMessage.mock.calls.some(([event]) => event.type === "session" && event.data.authenticated),
+ ).toBe(true);
+ expect(h.api.action.setBadgeText).toHaveBeenCalledWith({ text: "1" }, expect.any(Function));
+ expect(h.api.extension.getViews).not.toHaveBeenCalled();
+ h.client.stop();
+ });
+});
+
describe("discussion worker boundary", () => {
+ test("authenticated discussion traffic uses the account guard while anonymous reads keep their ordinary path", async () => {
+ const accountFetchImpl = jest.fn(async () => {
+ throw new Error("Authentication data consent required.");
+ });
+ const authenticated = harness({ authenticated: true, accountFetchImpl });
+ expect((await authenticated.send("session")).ok).toBe(false);
+ expect(accountFetchImpl).toHaveBeenCalledWith(
+ `${ORIGIN}/api/discussion/me`,
+ expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer account-token" }) }),
+ );
+ expect(authenticated.fetchImpl).not.toHaveBeenCalled();
+
+ accountFetchImpl.mockClear();
+ const anonymous = harness({ accountFetchImpl });
+ expect((await anonymous.send("comments", { videoId: VIDEO_ID, sort: "new" })).ok).toBe(true);
+ expect(anonymous.fetchImpl).toHaveBeenCalled();
+ expect(accountFetchImpl).not.toHaveBeenCalled();
+ });
+
test("public comments do not create a voter identity before the first reaction", async () => {
const h = harness();
h.voteClient.getRegisteredUser.mockResolvedValue(null);
diff --git a/Extensions/combined/src/discussion/client-content.spec.js b/Extensions/combined/src/discussion/client-content.spec.js
new file mode 100644
index 0000000..3377bbf
--- /dev/null
+++ b/Extensions/combined/src/discussion/client-content.spec.js
@@ -0,0 +1,31 @@
+/**
+ * @jest-environment jsdom
+ * @jest-environment-options {"url":"https://www.youtube.com/watch?v=abcdefghijk"}
+ */
+import { subscribeDiscussionEvents } from "./client";
+
+afterEach(() => {
+ delete global.browser;
+});
+
+test("Firefox content clients ignore page-level account events and retain tab messages", () => {
+ const onMessage = { addListener: jest.fn(), removeListener: jest.fn() };
+ global.browser = {
+ extension: { inIncognitoContext: false },
+ runtime: {
+ getManifest: () => ({ browser_specific_settings: { gecko: {} } }),
+ getURL: (path) => `moz-extension://extension-id/${path}`,
+ onMessage,
+ },
+ };
+ const callback = jest.fn();
+ const unsubscribe = subscribeDiscussionEvents(callback);
+ const message = { message: "ryd_discussion_event", type: "session", data: { authenticated: true } };
+ window.dispatchEvent(new CustomEvent("ryd-discussion-event", { detail: message }));
+ expect(callback).not.toHaveBeenCalled();
+ const receive = onMessage.addListener.mock.calls[0][0];
+ receive(message);
+ expect(callback).toHaveBeenCalledWith(message);
+ unsubscribe();
+ expect(onMessage.removeListener).toHaveBeenCalledWith(receive);
+});
diff --git a/Extensions/combined/src/discussion/client.js b/Extensions/combined/src/discussion/client.js
index 428e576..094a9fe 100644
--- a/Extensions/combined/src/discussion/client.js
+++ b/Extensions/combined/src/discussion/client.js
@@ -1,9 +1,15 @@
+import { isFirefoxExtension, isPrivateBrowsingContext } from "../private-browsing";
+
function runtimeApi() {
return typeof chrome !== "undefined" ? chrome.runtime : globalThis.browser?.runtime;
}
export function requestDiscussion(operation, payload = {}, scope) {
return new Promise((resolve, reject) => {
+ if (isPrivateBrowsingContext()) {
+ reject(Object.assign(new Error("Discussion service unavailable"), { code: "private_browsing" }));
+ return;
+ }
const runtime = runtimeApi();
if (!runtime?.sendMessage) {
reject(Object.assign(new Error("Discussion service unavailable"), { code: "unavailable" }));
@@ -79,6 +85,22 @@ export function createDiscussionRequestScope(videoId, request = requestDiscussio
}
export function subscribeDiscussionEvents(listener) {
+ if (isPrivateBrowsingContext()) return () => {};
+ const api = typeof browser !== "undefined" ? browser : globalThis.chrome;
+ const extensionUrl = api?.runtime?.getURL?.("");
+ if (
+ isFirefoxExtension(api) &&
+ typeof window !== "undefined" &&
+ window.location.protocol === "moz-extension:" &&
+ extensionUrl &&
+ window.location.href.startsWith(extensionUrl)
+ ) {
+ const receive = (event) => {
+ if (event.detail?.message === "ryd_discussion_event") listener(event.detail);
+ };
+ window.addEventListener("ryd-discussion-event", receive);
+ return () => window.removeEventListener("ryd-discussion-event", receive);
+ }
const event = runtimeApi()?.onMessage;
const receive = (message) => {
if (message?.message === "ryd_discussion_event") listener(message);
diff --git a/Extensions/combined/src/discussion/client.spec.js b/Extensions/combined/src/discussion/client.spec.js
index a76e93c..c65184a 100644
--- a/Extensions/combined/src/discussion/client.spec.js
+++ b/Extensions/combined/src/discussion/client.spec.js
@@ -1,8 +1,93 @@
-/** @jest-environment jsdom */
+/**
+ * @jest-environment jsdom
+ * @jest-environment-options {"url":"moz-extension://extension-id/popup.html"}
+ */
import { createDiscussionRequestScope, requestDiscussion, subscribeDiscussionEvents } from "./client";
+function firefoxApi(incognito = false) {
+ return {
+ extension: { inIncognitoContext: incognito },
+ runtime: {
+ getManifest: () => ({ browser_specific_settings: { gecko: {} } }),
+ getURL: (path) => `moz-extension://extension-id/${path}`,
+ sendMessage: jest.fn((message, done) => done({ ok: true, data: { authenticated: true } })),
+ onMessage: { addListener: jest.fn(), removeListener: jest.fn() },
+ },
+ };
+}
+
afterEach(() => {
delete global.chrome;
+ delete global.browser;
+});
+
+test("private Firefox requests fail before reaching the shared runtime", async () => {
+ global.browser = firefoxApi(true);
+ await expect(requestDiscussion("session")).rejects.toMatchObject({ code: "private_browsing" });
+ await expect(requestDiscussion("comments", { videoId: "abcdefghijk" })).rejects.toMatchObject({
+ code: "private_browsing",
+ });
+ expect(global.browser.runtime.sendMessage).not.toHaveBeenCalled();
+});
+
+test("private Firefox clients do not subscribe to either account event channel", () => {
+ global.browser = firefoxApi(true);
+ const addEventListener = jest.spyOn(window, "addEventListener");
+ const callback = jest.fn();
+ const unsubscribe = subscribeDiscussionEvents(callback);
+ window.dispatchEvent(
+ new CustomEvent("ryd-discussion-event", { detail: { message: "ryd_discussion_event", type: "inbox" } }),
+ );
+ expect(callback).not.toHaveBeenCalled();
+ expect(global.browser.runtime.onMessage.addListener).not.toHaveBeenCalled();
+ expect(addEventListener).not.toHaveBeenCalled();
+ unsubscribe();
+ addEventListener.mockRestore();
+});
+
+test("normal Firefox requests retain the runtime request channel", async () => {
+ global.browser = firefoxApi();
+ await expect(requestDiscussion("session")).resolves.toEqual({ authenticated: true });
+ expect(global.browser.runtime.sendMessage).toHaveBeenCalledTimes(1);
+});
+
+test("normal Firefox extension pages receive account events from their own view and unsubscribe", () => {
+ global.browser = firefoxApi();
+ const callback = jest.fn();
+ const unsubscribe = subscribeDiscussionEvents(callback);
+ window.dispatchEvent(new CustomEvent("ryd-discussion-event", { detail: { message: "unrelated" } }));
+ expect(callback).not.toHaveBeenCalled();
+ const data = { message: "ryd_discussion_event", type: "inbox", data: { unreadCount: 3 } };
+ window.dispatchEvent(new CustomEvent("ryd-discussion-event", { detail: data }));
+ expect(callback).toHaveBeenCalledWith(data);
+ expect(global.browser.runtime.onMessage.addListener).not.toHaveBeenCalled();
+ unsubscribe();
+ window.dispatchEvent(new CustomEvent("ryd-discussion-event", { detail: data }));
+ expect(callback).toHaveBeenCalledTimes(1);
+});
+
+test("does not accept view events on a different extension's page", () => {
+ global.browser = firefoxApi();
+ global.browser.runtime.getURL = (path) => `moz-extension://different-extension/${path}`;
+ const callback = jest.fn();
+ const unsubscribe = subscribeDiscussionEvents(callback);
+ window.dispatchEvent(new CustomEvent("ryd-discussion-event", { detail: { message: "ryd_discussion_event" } }));
+ expect(callback).not.toHaveBeenCalled();
+ const receive = global.browser.runtime.onMessage.addListener.mock.calls[0][0];
+ const message = { message: "ryd_discussion_event", type: "session", data: { authenticated: false } };
+ receive(message);
+ expect(callback).toHaveBeenCalledWith(message);
+ unsubscribe();
+ expect(global.browser.runtime.onMessage.removeListener).toHaveBeenCalledWith(receive);
+});
+
+test("preserves non-Firefox private request behavior", async () => {
+ global.chrome = {
+ extension: { inIncognitoContext: true },
+ runtime: { getManifest: () => ({}), sendMessage: jest.fn((message, done) => done({ ok: true, data: {} })) },
+ };
+ await expect(requestDiscussion("session")).resolves.toEqual({});
+ expect(global.chrome.runtime.sendMessage).toHaveBeenCalledTimes(1);
});
test("uses a typed worker envelope and returns only the response data", async () => {
diff --git a/Extensions/combined/src/discussion/index.js b/Extensions/combined/src/discussion/index.js
index 0827488..a0d5728 100644
--- a/Extensions/combined/src/discussion/index.js
+++ b/Extensions/combined/src/discussion/index.js
@@ -4,10 +4,12 @@ import { discussionLanguage, discussionText as t } from "./localize";
import { createDiscussionPanel } from "./panel";
import { showDiscussionToast } from "./inbox";
import { button, element, discussionLinkTarget, isDiscussionCommentId } from "./dom";
+import { isPrivateBrowsingContext } from "../private-browsing";
let installed = null;
export function initDiscussion({ request = requestDiscussion } = {}) {
+ if (isPrivateBrowsingContext()) return null;
if (installed) return installed;
let panel = null;
let panelScope = null;
diff --git a/Extensions/combined/src/discussion/index.spec.js b/Extensions/combined/src/discussion/index.spec.js
index 8a0dcdd..be88265 100644
--- a/Extensions/combined/src/discussion/index.spec.js
+++ b/Extensions/combined/src/discussion/index.spec.js
@@ -3,7 +3,13 @@
* @jest-environment-options {"url":"https://www.youtube.com/watch?v=abcdefghijk"}
*/
+jest.mock("../private-browsing", () => ({
+ ...jest.requireActual("../private-browsing"),
+ isPrivateBrowsingContext: jest.fn(),
+}));
+
import { initDiscussion } from "./index";
+import { isPrivateBrowsingContext } from "../private-browsing";
import { NATIVE_STATE_REQUEST, NATIVE_STATE_ATTRIBUTE } from "./detector";
import { threadUrl } from "./dom";
import { discussionText } from "./localize";
@@ -20,6 +26,7 @@ let listener;
let eventListener;
beforeEach(() => {
+ isPrivateBrowsingContext.mockReturnValue(false);
jest.useFakeTimers();
document.body.innerHTML = ``;
window.history.replaceState({}, "", `/watch?v=${VIDEO}`);
@@ -42,6 +49,24 @@ beforeEach(() => {
);
document.addEventListener(NATIVE_STATE_REQUEST, listener, true);
});
+
+test("private discussion initialization creates no requests, bridge, UI or subscriptions", () => {
+ isPrivateBrowsingContext.mockReturnValue(true);
+ const privateRequest = jest.fn();
+ const append = jest.spyOn(document.head, "append");
+ const timer = jest.spyOn(global, "setInterval");
+ try {
+ expect(initDiscussion({ request: privateRequest })).toBeNull();
+ expect(privateRequest).not.toHaveBeenCalled();
+ expect(append).not.toHaveBeenCalled();
+ expect(timer).not.toHaveBeenCalled();
+ expect(document.querySelector(".ryd-discussion-panel")).toBeNull();
+ expect(eventListener).toBeUndefined();
+ } finally {
+ append.mockRestore();
+ timer.mockRestore();
+ }
+});
afterEach(() => {
feature?.destroy();
document.removeEventListener(NATIVE_STATE_REQUEST, listener, true);
diff --git a/Extensions/combined/src/discussion/panel.js b/Extensions/combined/src/discussion/panel.js
index 0f6b9c2..354a82a 100644
--- a/Extensions/combined/src/discussion/panel.js
+++ b/Extensions/combined/src/discussion/panel.js
@@ -75,6 +75,7 @@ export function createDiscussionPanel({
let activeTab = "discussion";
const drafts = new Map();
const postingIds = new Map();
+ let draftGeneration = 0;
const rootReplyHandlers = new Map();
let moderationSequence = 0;
let reportCursor = null;
@@ -237,6 +238,8 @@ export function createDiscussionPanel({
if (submit.disabled) return;
const text = input.value.trim();
if (!text || text.length > maxLength) return;
+ const accountSnapshot = draftGeneration;
+ const stillCurrent = () => current() && accountSnapshot === draftGeneration;
submit.disabled = true;
input.disabled = true;
message.textContent = t("saving");
@@ -247,7 +250,7 @@ export function createDiscussionPanel({
? { id: comment.id, text }
: { videoId, text, requestId, ...(parentId ? { parentId, replyToId } : {}) };
const result = await request(comment ? "editComment" : "createComment", payload);
- if (!current()) return;
+ if (!stillCurrent()) return;
drafts.delete(key);
postingIds.delete(key);
if (onSaved) onSaved(result);
@@ -258,9 +261,9 @@ export function createDiscussionPanel({
await load();
}
} catch (error) {
- if (current()) message.textContent = discussionError(error);
+ if (stillCurrent()) message.textContent = discussionError(error);
} finally {
- if (current()) {
+ if (stillCurrent()) {
input.disabled = false;
submit.disabled = !input.value.trim();
}
@@ -676,6 +679,11 @@ export function createDiscussionPanel({
setSession(next) {
const previousIdentity = session.authenticated ? session.profile?.id || "authenticated" : "anonymous";
const nextIdentity = next?.authenticated ? next.profile?.id || "authenticated" : "anonymous";
+ if (previousIdentity !== nextIdentity) {
+ draftGeneration++;
+ drafts.clear();
+ postingIds.clear();
+ }
accountGeneration++;
moderationSequence++;
session = next || SIGNED_OUT;
diff --git a/Extensions/combined/src/discussion/panel.spec.js b/Extensions/combined/src/discussion/panel.spec.js
index 05ce6a0..ad4f410 100644
--- a/Extensions/combined/src/discussion/panel.spec.js
+++ b/Extensions/combined/src/discussion/panel.spec.js
@@ -3,6 +3,101 @@
import { createDiscussionPanel } from "./panel";
import { discussionText } from "./localize";
+const otherSession = {
+ authenticated: true,
+ canPost: true,
+ isModerator: false,
+ profile: { id: "55555555-5555-4555-8555-555555555555", nickname: "Other writer" },
+};
+
+function identityForm(root, kind) {
+ if (kind === "root") return root.querySelector(".ryd-discussion-composer form");
+ root.querySelector(`[data-action='${kind === "edit" ? "edit" : "reply"}']`).click();
+ return root.querySelector(".ryd-discussion-inline-editor form");
+}
+
+function typeIdentityDraft(form, text) {
+ const input = form.querySelector("textarea");
+ input.value = text;
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+}
+
+test.each(["root", "reply", "edit"])("identity change discards the previous account's %s draft", async (kind) => {
+ const root = await mount({ session });
+ typeIdentityDraft(identityForm(root, kind), "Private draft for first account");
+ panel.setSession(otherSession);
+ await flush();
+ expect(root.querySelector(".ryd-discussion-composer textarea").value).toBe("");
+ expect(root.querySelector("[data-action='edit']")).toBeNull();
+ panel.setSession(session);
+ await flush();
+ expect(identityForm(root, kind).querySelector("textarea").value).toBe(kind === "edit" ? "First comment" : "");
+});
+
+test("logout prevents a root draft returning after signing in again", async () => {
+ const root = await mount({ session });
+ typeIdentityDraft(identityForm(root, "root"), "First account draft");
+ panel.setSession({ authenticated: false, canPost: false, profile: null });
+ await flush();
+ panel.setSession(session);
+ await flush();
+ expect(identityForm(root, "root").querySelector("textarea").value).toBe("");
+});
+
+test("same-account refresh preserves a failed submission's retry ID", async () => {
+ Object.defineProperty(globalThis.crypto, "randomUUID", {
+ configurable: true,
+ value: jest
+ .fn()
+ .mockReturnValueOnce("44444444-4444-4444-8444-444444444444")
+ .mockReturnValue("66666666-6666-4666-8666-666666666666"),
+ });
+ const root = await mount({ session });
+ request.mockRejectedValueOnce({ code: "unavailable" });
+ submit(identityForm(root, "root"), "Retry my own draft");
+ await flush();
+ panel.setSession({ ...session });
+ await flush();
+ request.mockResolvedValueOnce(comment({ text: "Retry my own draft" }));
+ identityForm(root, "root").dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+ await flush();
+ const creates = request.mock.calls.filter(([operation]) => operation === "createComment");
+ expect(creates).toHaveLength(2);
+ expect(creates[1][1].requestId).toBe(creates[0][1].requestId);
+});
+
+test.each(["root", "reply", "edit"])("same-account refresh preserves its %s draft", async (kind) => {
+ const root = await mount({ session });
+ typeIdentityDraft(identityForm(root, kind), "Keep my own draft");
+ panel.setSession({ ...session, profile: { ...session.profile } });
+ await flush();
+ expect(identityForm(root, kind).querySelector("textarea").value).toBe("Keep my own draft");
+});
+
+test.each(["root", "reply", "edit"])("old %s completion cannot clear a draft after A to B to A", async (kind) => {
+ const root = await mount({ session });
+ let finish;
+ const operation = kind === "edit" ? "editComment" : "createComment";
+ request.mockImplementation((name) => {
+ if (name === operation)
+ return new Promise((resolve) => {
+ finish = resolve;
+ });
+ return Promise.resolve({ items: [comment()], nextCursor: null });
+ });
+ submit(identityForm(root, kind), "Old pending submission");
+ panel.setSession(otherSession);
+ await flush();
+ panel.setSession(session);
+ await flush();
+ typeIdentityDraft(identityForm(root, kind), "New draft after returning");
+ finish(comment({ ...(kind === "reply" ? { id: REPLY, parentId: ROOT } : {}), text: "Old pending submission" }));
+ await flush();
+ panel.setSession({ ...session });
+ await flush();
+ expect(identityForm(root, kind).querySelector("textarea").value).toBe("New draft after returning");
+});
+
const VIDEO = "abcdefghijk";
const ROOT = "11111111-1111-1111-1111-111111111111";
const REPLY = "22222222-2222-2222-2222-222222222222";
diff --git a/Extensions/combined/src/discussion/popup.js b/Extensions/combined/src/discussion/popup.js
index e9ea8de..916b435 100644
--- a/Extensions/combined/src/discussion/popup.js
+++ b/Extensions/combined/src/discussion/popup.js
@@ -2,8 +2,10 @@ import { requestDiscussion, subscribeDiscussionEvents } from "./client";
import { createDiscussionInbox } from "./inbox";
import { discussionText as t } from "./localize";
import { button, element } from "./dom";
+import { isPrivateBrowsingContext } from "../private-browsing";
export function initDiscussionPopup() {
+ if (isPrivateBrowsingContext()) return;
const root = document.getElementById("ryd-discussion-popup");
if (!root) return;
if (!document.documentElement.lang && chrome.i18n?.getUILanguage)
diff --git a/Extensions/combined/src/discussion/popup.spec.js b/Extensions/combined/src/discussion/popup.spec.js
index 32c8c50..2d27007 100644
--- a/Extensions/combined/src/discussion/popup.spec.js
+++ b/Extensions/combined/src/discussion/popup.spec.js
@@ -111,6 +111,29 @@ test("a shared inbox update preserves the current screen and its navigation", as
expect(document.activeElement).toBe(toggle);
});
+test("private Firefox popup initialization does not request or subscribe to account data", async () => {
+ await flush();
+ window.dispatchEvent(new Event("unload"));
+ document.body.innerHTML = new DOMParser().parseFromString(popupHtml, "text/html").body.innerHTML;
+ requestDiscussion.mockClear();
+ subscribeDiscussionEvents.mockClear();
+ global.browser = {
+ runtime: { getManifest: () => ({ browser_specific_settings: { gecko: {} } }) },
+ extension: { inIncognitoContext: true },
+ };
+ try {
+ initDiscussionPopup();
+ const privateRoot = document.getElementById("ryd-discussion-popup");
+ document.getElementById("ryd-discussion-inbox-toggle").click();
+ expect(privateRoot.hidden).toBe(true);
+ expect(privateRoot.childElementCount).toBe(0);
+ expect(requestDiscussion).not.toHaveBeenCalled();
+ expect(subscribeDiscussionEvents).not.toHaveBeenCalled();
+ } finally {
+ delete global.browser;
+ }
+});
+
test("Escape on the home screen leaves the popup unchanged", () => {
const event = new KeyboardEvent("keydown", { key: "Escape", cancelable: true });
document.dispatchEvent(event);
diff --git a/Extensions/combined/src/events.js b/Extensions/combined/src/events.js
index 9933b5d..e1269d3 100644
--- a/Extensions/combined/src/events.js
+++ b/Extensions/combined/src/events.js
@@ -1,3 +1,4 @@
+import { logger } from "./logging";
import { getBrowser, getVideoId, numberFormat, createObserver, querySelector } from "./utils";
import {
checkForSignInButton,
@@ -14,6 +15,7 @@ import {
storedData,
refreshReactionPresentation,
persistSyntheticShortsDislikeState,
+ updateDisableLogging,
} from "./state";
import { publishHideClutterButtons } from "./clutter-button-setting";
import { createRateBar } from "./bar";
@@ -28,8 +30,10 @@ import {
} from "../../common/vote-transition";
import { resolveDelegatedVoteActivation } from "../../common/delegated-vote-activation";
import { normalizeCustomColors, observeYouTubeTheme } from "../../common/color-themes";
+import { isPrivateBrowsingContext } from "./private-browsing";
function sendVote(vote) {
+ if (isPrivateBrowsingContext()) return;
if (shouldSubmitVote({ disableVoteSubmission: extConfig.disableVoteSubmission })) {
const result = getBrowser().runtime.sendMessage({
message: "send_vote",
@@ -69,6 +73,7 @@ function clearNativeLikeForSyntheticDislike(action, stateBeforeActivation, synth
}
function handleVoteAction(action) {
+ if (isPrivateBrowsingContext()) return;
if (checkForSignInButton() === false) {
const videoId = getVideoId(window.location.href);
if (!videoId || storedData.videoId !== videoId) return;
@@ -150,6 +155,10 @@ function ensureDelegatedReactionListener() {
function addLikeDislikeEventListener(likeButton = getLikeButton(), dislikeButton = getDislikeButton()) {
if (!likeButton || !dislikeButton) return;
+ if (isPrivateBrowsingContext()) {
+ observeReactionPresentation(likeButton, dislikeButton);
+ return;
+ }
ensureDelegatedReactionListener();
observeReactionPresentation(likeButton, dislikeButton);
if (!boundDislikeButtons.has(dislikeButton)) {
@@ -176,7 +185,7 @@ function createSmartimationObserver(buttons = getButtons()) {
const smartimationContainer = querySelector(extConfig.selectors.buttons.smartimation, buttons);
if (smartimationContainer && smartimationObserver.container != smartimationContainer) {
- console.log("Initializing smartimation mutation observer");
+ logger.log("Initializing smartimation mutation observer");
smartimationObserver.disconnect();
smartimationObserver.observe(smartimationContainer);
smartimationObserver.container = smartimationContainer;
@@ -184,6 +193,10 @@ function createSmartimationObserver(buttons = getButtons()) {
}
function storageChangeHandler(changes, area) {
+ if (isPrivateBrowsingContext()) return;
+ if (changes.disableLogging !== undefined && area === "sync") {
+ updateDisableLogging(changes.disableLogging.newValue);
+ }
if (changes.disableVoteSubmission !== undefined) {
handleDisableVoteSubmissionChangeEvent(changes.disableVoteSubmission.newValue);
}
@@ -203,6 +216,16 @@ function storageChangeHandler(changes, area) {
if (changes.numberDisplayFormat !== undefined) {
handleNumberDisplayFormatChangeEvent(changes.numberDisplayFormat.newValue);
}
+ if (area === "sync" && (changes.showTooltipPercentage !== undefined || changes.tooltipPercentageMode !== undefined)) {
+ if (changes.showTooltipPercentage !== undefined) {
+ extConfig.showTooltipPercentage = changes.showTooltipPercentage.newValue ?? false;
+ }
+ if (changes.tooltipPercentageMode !== undefined) {
+ extConfig.tooltipPercentageMode = changes.tooltipPercentageMode.newValue || "dash_like";
+ }
+ const videoId = getVideoId(window.location.href);
+ if (videoId && storedData.videoId === videoId) createRateBar(storedData.likes, storedData.dislikes);
+ }
if (changes.numberDisplayReformatLikes !== undefined) {
handleNumberDisplayReformatLikesChangeEvent(changes.numberDisplayReformatLikes.newValue);
}
@@ -249,6 +272,7 @@ function observeColorTheme() {
function handleNumberDisplayFormatChangeEvent(value) {
extConfig.numberDisplayFormat = value;
refreshReactionPresentation();
+ updateDOMDislikes();
}
function handleNumberDisplayReformatLikesChangeEvent(value) {
diff --git a/Extensions/combined/src/events.spec.js b/Extensions/combined/src/events.spec.js
index 70cd0f3..4548989 100644
--- a/Extensions/combined/src/events.spec.js
+++ b/Extensions/combined/src/events.spec.js
@@ -5,6 +5,11 @@
const mockSendMessage = jest.fn();
+jest.mock("./private-browsing", () => ({
+ ...jest.requireActual("./private-browsing"),
+ isPrivateBrowsingContext: jest.fn(),
+}));
+
jest.mock("./utils", () => ({
createObserver: () => ({ disconnect: jest.fn(), observe: jest.fn() }),
getBrowser: jest.fn(),
@@ -19,7 +24,7 @@ jest.mock("./buttons", () => ({
getDislikeButton: jest.fn(),
getLikeButton: jest.fn(),
getNativeButton: (control) => control?.querySelector("button[aria-pressed]"),
- isSyntheticShortsDislike: () => false,
+ isSyntheticShortsDislike: jest.fn(() => false),
setSyntheticShortsDislikePressed: jest.fn(),
}));
@@ -32,6 +37,7 @@ jest.mock("./state", () => ({
getLikeCountFromButton: jest.fn(),
persistSyntheticShortsDislikeState: jest.fn(async () => undefined),
setDislikes: jest.fn(),
+ updateDisableLogging: jest.fn(),
setLikes: jest.fn(),
refreshReactionPresentation: jest.fn(),
storedData: { dislikes: 100, likes: 300, previousState: "neutral", videoId: "abcdefghijk" },
@@ -42,12 +48,23 @@ jest.mock("./bar", () => ({
}));
import { createRateBar } from "./bar";
-import { getButtons, getDislikeButton, getLikeButton } from "./buttons";
-import { extConfig, setDislikes, storedData } from "./state";
-import { addLikeDislikeEventListener, storageChangeHandler, updateDOMDislikes } from "./events";
+import { getButtons, getDislikeButton, getLikeButton, isSyntheticShortsDislike } from "./buttons";
+import { extConfig, persistSyntheticShortsDislikeState, setDislikes, storedData, updateDisableLogging } from "./state";
+import {
+ addLikeDislikeEventListener,
+ dislikeClicked,
+ likeClicked,
+ sendVote,
+ storageChangeHandler,
+ updateDOMDislikes,
+} from "./events";
+import { isPrivateBrowsingContext } from "./private-browsing";
import { getBrowser } from "./utils";
beforeEach(() => {
+ isPrivateBrowsingContext.mockReturnValue(false);
+ isSyntheticShortsDislike.mockReturnValue(false);
+ extConfig.disableVoteSubmission = false;
createRateBar.mockClear();
setDislikes.mockClear();
mockSendMessage.mockReset();
@@ -62,6 +79,47 @@ beforeEach(() => {
history.replaceState({}, "", "/watch?v=abcdefghijk");
});
+test.each([false, true])("private direct reactions do not alter counts or submit (disabled=%s)", (disabled) => {
+ isPrivateBrowsingContext.mockReturnValue(true);
+ extConfig.disableVoteSubmission = disabled;
+ storedData.previousState = "LIKED_STATE";
+ const before = { ...storedData };
+ const likeButton = document.createElement("div");
+ const nativeLike = document.createElement("button");
+ const nativeLikeClick = jest.fn();
+ nativeLike.addEventListener("click", nativeLikeClick);
+ likeButton.append(nativeLike);
+ getLikeButton.mockReturnValue(likeButton);
+ isSyntheticShortsDislike.mockReturnValue(true);
+
+ sendVote(-1);
+ dislikeClicked();
+ likeClicked();
+
+ expect(mockSendMessage).not.toHaveBeenCalled();
+ expect(nativeLikeClick).not.toHaveBeenCalled();
+ expect(persistSyntheticShortsDislikeState).not.toHaveBeenCalled();
+ expect(storedData).toEqual(before);
+ expect(setDislikes).not.toHaveBeenCalled();
+});
+
+test("private native reactions keep their YouTube click behavior without local reaction changes", () => {
+ isPrivateBrowsingContext.mockReturnValue(true);
+ const controls = createReactionControls("private");
+ document.body.replaceChildren(controls.buttons);
+ getButtons.mockReturnValue(controls.buttons);
+ getLikeButton.mockReturnValue(controls.likeButton);
+ getDislikeButton.mockReturnValue(controls.dislikeButton);
+ const nativeAction = jest.fn();
+ controls.nativeDislikeButton.addEventListener("click", nativeAction);
+ const before = { ...storedData };
+ addLikeDislikeEventListener(controls.likeButton, controls.dislikeButton);
+ controls.nativeDislikeButton.click();
+ expect(nativeAction).toHaveBeenCalledTimes(1);
+ expect(mockSendMessage).not.toHaveBeenCalled();
+ expect(storedData).toEqual(before);
+});
+
test("renders cached counts only while they belong to the current video", () => {
expect(updateDOMDislikes()).toBe(true);
expect(setDislikes).toHaveBeenCalledWith("100");
@@ -84,6 +142,22 @@ test("does not render cached counts when the current URL has no video identity",
expect(createRateBar).not.toHaveBeenCalled();
});
+test.each([true, false, undefined])("applies live sync logging preference changes (%s)", (newValue) => {
+ storageChangeHandler({ disableLogging: { oldValue: !newValue, newValue } }, "sync");
+ expect(updateDisableLogging).toHaveBeenCalledWith(newValue);
+});
+
+test.each(["local", "session", "managed", undefined])("ignores logging preference events outside sync (%s)", (area) => {
+ storageChangeHandler({ disableLogging: { newValue: false } }, area);
+ expect(updateDisableLogging).not.toHaveBeenCalled();
+});
+
+test("private tabs ignore normal logging preference changes", () => {
+ isPrivateBrowsingContext.mockReturnValue(true);
+ storageChangeHandler({ disableLogging: { newValue: false } }, "sync");
+ expect(updateDisableLogging).not.toHaveBeenCalled();
+});
+
test("publishes hide-clutter setting changes to the page-world menu fixer", () => {
storageChangeHandler({ hideClutterButtons: { newValue: true } }, "sync");
diff --git a/Extensions/combined/src/logging.js b/Extensions/combined/src/logging.js
new file mode 100644
index 0000000..35fe3f5
--- /dev/null
+++ b/Extensions/combined/src/logging.js
@@ -0,0 +1,14 @@
+let loggingDisabled = false;
+
+export function setLoggingDisabled(value) {
+ loggingDisabled = value;
+}
+
+export const logger = {
+ log(...args) {
+ if (!loggingDisabled) console.log(...args);
+ },
+ debug(...args) {
+ if (!loggingDisabled) console.debug(...args);
+ },
+};
diff --git a/Extensions/combined/src/logging.spec.js b/Extensions/combined/src/logging.spec.js
new file mode 100644
index 0000000..309d94b
--- /dev/null
+++ b/Extensions/combined/src/logging.spec.js
@@ -0,0 +1,34 @@
+import { logger, setLoggingDisabled } from "./logging";
+
+afterEach(() => {
+ setLoggingDisabled(false);
+ jest.restoreAllMocks();
+});
+
+test("suppresses only extension log/debug calls without changing the console", () => {
+ const methods = Object.fromEntries(
+ ["log", "debug", "info", "warn", "error"].map((name) => [
+ name,
+ jest.spyOn(console, name).mockImplementation(() => {}),
+ ]),
+ );
+ const payload = { count: 42 };
+
+ for (const disabled of [true, false, true, false]) {
+ Object.values(methods).forEach((method) => method.mockClear());
+ setLoggingDisabled(disabled);
+ logger.log("extension log", payload);
+ logger.debug("extension debug", payload);
+ for (const [name, method] of Object.entries(methods)) {
+ expect(console[name]).toBe(method);
+ console[name]("unrelated message");
+ const expectedCalls = !disabled && ["log", "debug"].includes(name) ? 2 : 1;
+ expect(method).toHaveBeenCalledTimes(expectedCalls);
+ expect(method).toHaveBeenLastCalledWith("unrelated message");
+ if (expectedCalls === 2) {
+ expect(method).toHaveBeenNthCalledWith(1, `extension ${name}`, payload);
+ expect(method.mock.contexts[0]).toBe(console);
+ }
+ }
+ }
+});
diff --git a/Extensions/combined/src/patreon.js b/Extensions/combined/src/patreon.js
index 027f439..ce52a37 100644
--- a/Extensions/combined/src/patreon.js
+++ b/Extensions/combined/src/patreon.js
@@ -1,5 +1,6 @@
import { initPremiumAnalytics, teardownPremiumAnalytics, updatePremiumSession } from "./premiumAnalytics";
import { initPremiumTeaser, setTeaserSuppressed, TEASER_SUPPRESSION_REASON_PREMIUM } from "./premiumAnalytics/teaser";
+import { isPrivateBrowsingContext } from "./private-browsing";
import {
hasAuthenticationDataPermission,
onAuthenticationDataPermissionRemoved,
@@ -13,6 +14,7 @@ let patreonState = {
};
function initPatreonFeatures() {
+ if (isPrivateBrowsingContext()) return;
initPremiumTeaser();
let sessionGeneration = 0;
const initialGeneration = sessionGeneration;
diff --git a/Extensions/combined/src/patreon.spec.js b/Extensions/combined/src/patreon.spec.js
index e19a35d..59c3aff 100644
--- a/Extensions/combined/src/patreon.spec.js
+++ b/Extensions/combined/src/patreon.spec.js
@@ -20,6 +20,12 @@ jest.mock("./data-collection-permissions", () => ({
usesFirefoxDataCollectionConsent: jest.fn(),
}));
+jest.mock("./private-browsing", () => ({
+ ...jest.requireActual("./private-browsing"),
+ isPrivateBrowsingContext: jest.fn(),
+}));
+import { isPrivateBrowsingContext } from "./private-browsing";
+
import { initPatreonFeatures, patreonState } from "./patreon";
const analyticsMocks = jest.requireMock("./premiumAnalytics");
@@ -32,6 +38,7 @@ describe("Patreon authentication consent", () => {
let permissionRemovedListener;
beforeEach(() => {
+ isPrivateBrowsingContext.mockReturnValue(false);
jest.clearAllMocks();
document.body.innerHTML = '';
patreonState.authenticated = false;
@@ -65,6 +72,19 @@ describe("Patreon authentication consent", () => {
delete global.chrome;
});
+ it("skips all private account reads, teasers and session listeners even when initialized directly", async () => {
+ isPrivateBrowsingContext.mockReturnValue(true);
+ consentMocks.hasAuthenticationDataPermission.mockResolvedValue(true);
+ initPatreonFeatures();
+ await flushPromises();
+ expect(chrome.storage.sync.get).not.toHaveBeenCalled();
+ expect(chrome.runtime.onMessage.addListener).not.toHaveBeenCalled();
+ expect(consentMocks.hasAuthenticationDataPermission).not.toHaveBeenCalled();
+ expect(consentMocks.onAuthenticationDataPermissionRemoved).not.toHaveBeenCalled();
+ expect(jest.requireMock("./premiumAnalytics/teaser").initPremiumTeaser).not.toHaveBeenCalled();
+ expect(analyticsMocks.initPremiumAnalytics).not.toHaveBeenCalled();
+ });
+
it("does not read a cached token without authentication consent", async () => {
initPatreonFeatures();
await flushPromises();
diff --git a/Extensions/combined/src/premiumAnalytics/activity/index.js b/Extensions/combined/src/premiumAnalytics/activity/index.js
index 15d7cd1..e34d5ca 100644
--- a/Extensions/combined/src/premiumAnalytics/activity/index.js
+++ b/Extensions/combined/src/premiumAnalytics/activity/index.js
@@ -5,7 +5,7 @@ import { setActivityBucketLabel } from "../panel";
import { clampRangeToBounds, combineBounds, computeChartBounds, updateGlobalBounds } from "./time";
import { toEpoch, sanitizeCount } from "../utils";
import { localize } from "../../utils";
-import { getTextColor, getMutedTextColor, getBorderColor } from "../theme";
+import { getTextColor, getMutedTextColor, getBorderColor, getSurfaceColor } from "../theme";
import { logRangeSelection, logTimeBounds } from "../logging";
let activityTranslator = (key, substitutions) => localize(key, substitutions);
@@ -273,6 +273,28 @@ export function resizeActivityChart() {
analyticsState.activityChart?.resize();
}
+export function refreshActivityTheme() {
+ analyticsState.activityChart?.setOption({
+ legend: { textStyle: { color: getTextColor() } },
+ xAxis: {
+ axisLabel: { color: getMutedTextColor() },
+ axisLine: { lineStyle: { color: getBorderColor() } },
+ },
+ yAxis: {
+ axisLabel: { color: getMutedTextColor() },
+ splitLine: { lineStyle: { color: getBorderColor(0.35) } },
+ },
+ dataZoom: [
+ {
+ backgroundColor: getSurfaceColor(0.05, 0.05),
+ borderColor: getBorderColor(0.1),
+ textStyle: { color: getMutedTextColor() },
+ handleStyle: { color: getTextColor(), borderColor: getBorderColor(0.6) },
+ },
+ ],
+ });
+}
+
export function disposeActivityChart() {
if (analyticsState.activityChart) {
analyticsState.activityChart.off?.("dataZoom", handleDataZoom);
@@ -297,8 +319,8 @@ function createDataZoom(bounds, selection) {
xAxisIndex: 0,
height: 18,
bottom: 20,
- backgroundColor: "rgba(255,255,255,0.05)",
- borderColor: "rgba(255,255,255,0.1)",
+ backgroundColor: getSurfaceColor(0.05, 0.05),
+ borderColor: getBorderColor(0.1),
textStyle: { color: getMutedTextColor() },
handleStyle: { color: getTextColor(), borderColor: getBorderColor(0.6) },
realtime: true,
diff --git a/Extensions/combined/src/premiumAnalytics/activity/index.spec.js b/Extensions/combined/src/premiumAnalytics/activity/index.spec.js
index 85072b4..edb269f 100644
--- a/Extensions/combined/src/premiumAnalytics/activity/index.spec.js
+++ b/Extensions/combined/src/premiumAnalytics/activity/index.spec.js
@@ -30,6 +30,7 @@ jest.mock("../theme", () => ({
getTextColor: jest.fn(() => "#111111"),
getMutedTextColor: jest.fn(() => "#222222"),
getBorderColor: jest.fn(() => "#333333"),
+ getSurfaceColor: jest.fn(() => "#444444"),
}));
jest.mock("../logging", () => ({
@@ -46,6 +47,7 @@ import {
clearActivityChart,
resetChartZoom,
resizeActivityChart,
+ refreshActivityTheme,
disposeActivityChart,
registerZoomSelectionListener,
setActivityTranslator,
@@ -134,6 +136,34 @@ describe("premiumAnalytics.activity", () => {
jest.restoreAllMocks();
});
+ it("refreshes only chart colors while keeping the selected range and series", () => {
+ const theme = jest.requireMock("../theme");
+ theme.getTextColor.mockReturnValue("#111111");
+ theme.getMutedTextColor.mockReturnValue("#222222");
+ theme.getBorderColor.mockReturnValue("#333333");
+ theme.getSurfaceColor.mockReturnValue("#444444");
+ const chart = ensureActivityChart();
+ analyticsState.selectionRange = { from: 10, to: 20 };
+ const points = [{ timestampUtc: "2026-01-01T00:00:00Z", likes: 4, dislikes: 1 }];
+ analyticsState.latestSeriesPoints = points;
+ refreshActivityTheme();
+ expect(chart.setOption).toHaveBeenCalledWith(
+ expect.objectContaining({
+ legend: { textStyle: { color: "#111111" } },
+ xAxis: expect.objectContaining({ axisLabel: { color: "#222222" } }),
+ yAxis: expect.objectContaining({ axisLabel: { color: "#222222" } }),
+ dataZoom: [expect.objectContaining({ backgroundColor: "#444444" })],
+ }),
+ );
+ const update = chart.setOption.mock.calls.at(-1)[0];
+ expect(update).not.toHaveProperty("series");
+ expect(update.dataZoom[0]).not.toHaveProperty("startValue");
+ expect(update.dataZoom[0]).not.toHaveProperty("endValue");
+ expect(analyticsState.selectionRange).toEqual({ from: 10, to: 20 });
+ expect(analyticsState.latestSeriesPoints).toBe(points);
+ expect(chart.dispatchAction).not.toHaveBeenCalled();
+ });
+
it("initializes and caches the chart", () => {
const chart = ensureActivityChart();
@@ -162,7 +192,6 @@ describe("premiumAnalytics.activity", () => {
expect(logging.logRangeSelection).toHaveBeenCalled();
expect(analyticsState.latestSeriesPoints).toHaveLength(2);
expect(analyticsState.selectionRange.from).toBeLessThanOrEqual(analyticsState.selectionRange.to);
-
});
it("updates the bucket label when rendering", () => {
diff --git a/Extensions/combined/src/premiumAnalytics/activity/layout.spec.js b/Extensions/combined/src/premiumAnalytics/activity/layout.spec.js
index 8e54844..5f444fd 100644
--- a/Extensions/combined/src/premiumAnalytics/activity/layout.spec.js
+++ b/Extensions/combined/src/premiumAnalytics/activity/layout.spec.js
@@ -10,6 +10,7 @@ jest.mock("../theme", () => ({
getTextColor: () => "#ffffff",
getMutedTextColor: () => "#b3b3b3",
getBorderColor: () => "#444444",
+ getSurfaceColor: () => "rgba(255,255,255,0.06)",
}));
jest.mock("../logging", () => ({ logRangeSelection: jest.fn(), logTimeBounds: jest.fn() }));
diff --git a/Extensions/combined/src/premiumAnalytics/index.spec.js b/Extensions/combined/src/premiumAnalytics/index.spec.js
index 77c469a..f5579f3 100644
--- a/Extensions/combined/src/premiumAnalytics/index.spec.js
+++ b/Extensions/combined/src/premiumAnalytics/index.spec.js
@@ -9,6 +9,7 @@ jest.mock("../config", () => ({
jest.mock("../data-collection-permissions", () => ({
hasAuthenticationDataPermission: jest.fn(),
+ onAuthenticationDataPermissionRemoved: () => () => {},
usesFirefoxDataCollectionConsent: jest.fn(),
}));
@@ -33,6 +34,7 @@ jest.mock("./activity", () => ({
clearActivityChart: jest.fn(),
resetChartZoom: jest.fn(),
resizeActivityChart: jest.fn(),
+ refreshActivityTheme: jest.fn(),
disposeActivityChart: jest.fn(),
registerZoomSelectionListener: jest.fn(),
}));
@@ -40,6 +42,7 @@ jest.mock("./activity", () => ({
jest.mock("./map", () => ({
ensureMapChart: jest.fn(),
renderMap: jest.fn(),
+ refreshMapTheme: jest.fn(),
clearMapChart: jest.fn(),
resizeMapChart: jest.fn(),
disposeMapChart: jest.fn(),
@@ -210,10 +213,36 @@ describe("premiumAnalytics", () => {
});
afterEach(() => {
+ teardownPremiumAnalytics();
+ document.documentElement.removeAttribute("dark");
delete global.fetch;
delete global.chrome;
});
+ it("repaints existing charts after a host theme toggle without fetching or changing the selected range", async () => {
+ document.documentElement.removeAttribute("dark");
+ initPremiumAnalytics();
+ await flushPromises();
+ analyticsState.selectionRange = { from: 10, to: 20 };
+ analyticsState.mapView = "us";
+ fetch.mockClear();
+ activityMocks.refreshActivityTheme.mockClear();
+ mapMocks.refreshMapTheme.mockClear();
+ mockRenderMap.mockClear();
+ document.documentElement.setAttribute("dark", "");
+ await flushPromises();
+ expect(activityMocks.refreshActivityTheme).toHaveBeenCalledTimes(1);
+ expect(mapMocks.refreshMapTheme).toHaveBeenCalledTimes(1);
+ expect(mockRenderMap).not.toHaveBeenCalled();
+ expect(fetch).not.toHaveBeenCalled();
+ expect(analyticsState.selectionRange).toEqual({ from: 10, to: 20 });
+ expect(analyticsState.mapView).toBe("us");
+ teardownPremiumAnalytics();
+ document.documentElement.removeAttribute("dark");
+ await flushPromises();
+ expect(activityMocks.refreshActivityTheme).toHaveBeenCalledTimes(1);
+ });
+
it("initializes only once and wires listeners", () => {
initPremiumAnalytics();
expect(mockConfigurePanelCallbacks).toHaveBeenCalled();
@@ -461,6 +490,27 @@ describe("premiumAnalytics", () => {
expect(firstUrl).toBe(secondUrl);
});
+ it("restores a preset when a custom selection rounds to the same number of days", async () => {
+ mockGetApiEndpoint.mockImplementation((path) => `https://api.example${path}`);
+ initPremiumAnalytics();
+ const callbacks = mockConfigurePanelCallbacks.mock.calls.at(-1)[0];
+ analyticsState.currentVideoId = "abcdefghijk";
+ analyticsState.currentRange = 30;
+ analyticsState.usingCustomRange = true;
+ analyticsState.customSelection = { from: 10, to: 20 };
+ analyticsState.selectionRange = { from: 10, to: 20 };
+ fetch.mockClear();
+
+ callbacks.onRangePreset(30);
+
+ expect(analyticsState.usingCustomRange).toBe(false);
+ expect(analyticsState.customSelection).toBeNull();
+ expect(analyticsState.selectionRange).toEqual({ from: null, to: null });
+ await flushPromises();
+ expect(fetch).toHaveBeenCalledTimes(1);
+ expect(new URL(fetch.mock.calls[0][0]).searchParams.has("selectedRangeStartUtc")).toBe(false);
+ });
+
it("defers chart zoom resets until preset data arrives", () => {
initPremiumAnalytics();
const callbacks = mockConfigurePanelCallbacks.mock.calls.at(-1)?.[0];
diff --git a/Extensions/combined/src/premiumAnalytics/lifecycle.js b/Extensions/combined/src/premiumAnalytics/lifecycle.js
index a508a69..78e205f 100644
--- a/Extensions/combined/src/premiumAnalytics/lifecycle.js
+++ b/Extensions/combined/src/premiumAnalytics/lifecycle.js
@@ -7,8 +7,13 @@ import {
updateModeButtons,
applyChartExpansionState,
} from "./panel";
-import { resizeActivityChart, disposeActivityChart, registerZoomSelectionListener } from "./activity";
-import { renderMap, resizeMapChart, disposeMapChart } from "./map";
+import {
+ resizeActivityChart,
+ disposeActivityChart,
+ registerZoomSelectionListener,
+ refreshActivityTheme,
+} from "./activity";
+import { renderMap, resizeMapChart, disposeMapChart, refreshMapTheme } from "./map";
import { debounce } from "./utils";
import { getVideoId } from "../utils";
@@ -16,12 +21,18 @@ import { MS_PER_DAY, EXPANDABLE_SECTIONS } from "./constants";
import { requestAnalytics, scheduleSelectionFetch, normalizeSelection } from "./requests";
import { setTeaserSuppressed, TEASER_SUPPRESSION_REASON_PREMIUM } from "./teaser";
import { showTierNotice, hideTierNotice } from "./tierNotice";
+import { observeTheme } from "./theme";
let resizeListener = null;
+let themeObserver = null;
function initPremiumAnalytics() {
if (analyticsState.initialized) return;
analyticsState.initialized = true;
+ themeObserver = observeTheme(() => {
+ refreshActivityTheme();
+ refreshMapTheme();
+ });
if (!analyticsState.zoomListenerRegistered) {
registerZoomSelectionListener(handleChartSelection);
@@ -65,7 +76,7 @@ function handleNavigation() {
function handleRangePreset(rangeDays) {
const state = analyticsState;
- const samePreset = rangeDays === state.currentRange;
+ const samePreset = !state.usingCustomRange && rangeDays === state.currentRange;
if (samePreset) {
return;
}
@@ -135,8 +146,7 @@ function handleChartSelection(range) {
}
const sameAsCurrent =
- analyticsState.selectionRange.from === normalized.from &&
- analyticsState.selectionRange.to === normalized.to;
+ analyticsState.selectionRange.from === normalized.from && analyticsState.selectionRange.to === normalized.to;
analyticsState.selectionRange = normalized;
analyticsState.customSelection = { ...normalized };
@@ -169,6 +179,8 @@ function teardownPanel() {
}
function teardownPremiumAnalytics() {
+ themeObserver?.disconnect();
+ themeObserver = null;
teardownPanel();
if (resizeListener) {
window.removeEventListener("resize", resizeListener);
diff --git a/Extensions/combined/src/premiumAnalytics/logging.js b/Extensions/combined/src/premiumAnalytics/logging.js
index 330fc62..c63bec7 100644
--- a/Extensions/combined/src/premiumAnalytics/logging.js
+++ b/Extensions/combined/src/premiumAnalytics/logging.js
@@ -1,8 +1,10 @@
+import { logger } from "../logging";
+
const LOG_PREFIX = '[PremiumAnalytics]';
export function logTimeBounds(label, bounds) {
if (!bounds) return;
- console.log(
+ logger.log(
`${LOG_PREFIX} ${label} bounds:`,
bounds.min != null ? new Date(bounds.min).toISOString() : 'null',
'->',
@@ -12,7 +14,7 @@ export function logTimeBounds(label, bounds) {
export function logRangeSelection(label, range) {
if (!range) return;
- console.log(
+ logger.log(
`${LOG_PREFIX} ${label} selection:`,
range.from != null ? new Date(range.from).toISOString() : 'null',
'->',
@@ -21,12 +23,12 @@ export function logRangeSelection(label, range) {
}
export function logFetchRequest(videoId, params) {
- console.log(`${LOG_PREFIX} fetch video=${videoId} params=`, params.toString());
+ logger.log(`${LOG_PREFIX} fetch video=${videoId} params=`, params.toString());
}
export function logZoomPreview(label, bounds) {
if (!bounds) return;
- console.log(
+ logger.log(
`${LOG_PREFIX} ${label} preview:`,
new Date(bounds.from).toISOString(),
'->',
diff --git a/Extensions/combined/src/premiumAnalytics/logging.spec.js b/Extensions/combined/src/premiumAnalytics/logging.spec.js
index 1e2cf2d..c0581ed 100644
--- a/Extensions/combined/src/premiumAnalytics/logging.spec.js
+++ b/Extensions/combined/src/premiumAnalytics/logging.spec.js
@@ -2,6 +2,7 @@
* @jest-environment jsdom
*/
+import { setLoggingDisabled } from "../logging";
import {
logTimeBounds,
logRangeSelection,
@@ -17,9 +18,20 @@ describe("premiumAnalytics.logging", () => {
});
afterEach(() => {
+ setLoggingDisabled(false);
consoleSpy.mockRestore();
});
+ it("suppresses analytics messages without replacing console.log", () => {
+ setLoggingDisabled(true);
+ logTimeBounds("chart", { min: 0, max: 1 });
+ logRangeSelection("selection", { from: 0, to: 1 });
+ logFetchRequest("video-id", new URLSearchParams({ a: "1" }));
+ logZoomPreview("preview", { from: 3, to: 4 });
+ expect(consoleSpy).not.toHaveBeenCalled();
+ expect(console.log).toBe(consoleSpy);
+ });
+
it("logs time bounds when values are provided", () => {
logTimeBounds("chart", { min: 0, max: 1 });
diff --git a/Extensions/combined/src/premiumAnalytics/map/index.js b/Extensions/combined/src/premiumAnalytics/map/index.js
index f706526..1871373 100644
--- a/Extensions/combined/src/premiumAnalytics/map/index.js
+++ b/Extensions/combined/src/premiumAnalytics/map/index.js
@@ -238,6 +238,20 @@ export function renderMap(countries) {
updateMapResetButtonVisibility();
}
+export function refreshMapTheme() {
+ analyticsState.mapChart?.setOption({
+ visualMap: { textStyle: { color: getMutedTextColor() } },
+ geo: {
+ itemStyle: {
+ borderColor: getBorderColor(0.4),
+ borderWidth: 0.6,
+ areaColor: getSurfaceColor(0.06, 0.02),
+ },
+ emphasis: { itemStyle: { areaColor: getHoverFillColor(0.18, 0.18) } },
+ },
+ });
+}
+
function resolveMapContext(state) {
const focusCode = typeof state.mapFocusCountry === "string" ? normalizeIsoCode(state.mapFocusCountry) : "";
const subdivisions = Array.isArray(state.latestSubdivisions) ? state.latestSubdivisions : [];
diff --git a/Extensions/combined/src/premiumAnalytics/map/index.spec.js b/Extensions/combined/src/premiumAnalytics/map/index.spec.js
index 2543de1..f5e7bdb 100644
--- a/Extensions/combined/src/premiumAnalytics/map/index.spec.js
+++ b/Extensions/combined/src/premiumAnalytics/map/index.spec.js
@@ -101,6 +101,7 @@ import { analyticsState } from "../state";
import {
ensureMapChart,
renderMap,
+ refreshMapTheme,
disposeMapChart,
clearMapChart,
resizeMapChart,
@@ -110,6 +111,25 @@ import {
const enMessages = require("../../../_locales/en/messages.json");
+test("theme refresh merges only colors without replacing map data, center or zoom", () => {
+ const chart = { setOption: jest.fn() };
+ analyticsState.mapChart = chart;
+ try {
+ refreshMapTheme();
+ expect(chart.setOption).toHaveBeenCalledTimes(1);
+ expect(chart.setOption.mock.calls[0]).toHaveLength(1);
+ const update = chart.setOption.mock.calls[0][0];
+ expect(Object.keys(update).sort()).toEqual(["geo", "visualMap"]);
+ expect(Object.keys(update.geo).sort()).toEqual(["emphasis", "itemStyle"]);
+ expect(update.geo).not.toHaveProperty("center");
+ expect(update.geo).not.toHaveProperty("zoom");
+ expect(update.geo).not.toHaveProperty("map");
+ expect(update).not.toHaveProperty("series");
+ } finally {
+ analyticsState.mapChart = null;
+ }
+});
+
function getMessage(key, substitutions) {
const entry = enMessages[key];
if (!entry) return key;
diff --git a/Extensions/combined/src/premiumAnalytics/panel.js b/Extensions/combined/src/premiumAnalytics/panel.js
index 9d35eb1..d79d9d0 100644
--- a/Extensions/combined/src/premiumAnalytics/panel.js
+++ b/Extensions/combined/src/premiumAnalytics/panel.js
@@ -418,7 +418,7 @@ function positionExpandedSection(section, button) {
applyAnchorPosition(section, anchorRect, { maxWidthFallback: "min(960px, calc(100vw - 48px))" });
section.style.maxHeight = "calc(100vh - 120px)";
section.style.zIndex = "2147483647";
- section.style.background = "var(--yt-spec-base-background, #202020)";
+ section.style.background = "var(--ryd-analytics-bg)";
section.style.boxShadow = "0 30px 60px rgba(0, 0, 0, 0.45)";
section.style.padding = "16px";
section.style.overflowY = "auto";
diff --git a/Extensions/combined/src/premiumAnalytics/requests.js b/Extensions/combined/src/premiumAnalytics/requests.js
index fa80fca..8afe533 100644
--- a/Extensions/combined/src/premiumAnalytics/requests.js
+++ b/Extensions/combined/src/premiumAnalytics/requests.js
@@ -1,5 +1,5 @@
import { getApiEndpoint } from "../config";
-import { fetchWithCoreConsent } from "../core-data-consent";
+import { fetchWithAccountConsent } from "../account-data-consent";
import { analyticsState, COUNTRY_LIMIT } from "./state";
import { ensurePanel, updateRangeButtons, updateRangeAnchorButtons, setFooterMessage, setLoadingState } from "./panel";
import { debounce, safeJson } from "./utils";
@@ -104,7 +104,7 @@ function requestAnalyticsWithConsent({ selection } = {}) {
analyticsState.sessionToken === sessionToken &&
analyticsState.sessionActive;
- fetchWithCoreConsent(url, {
+ fetchWithAccountConsent(url, {
headers: {
Authorization: `Bearer ${sessionToken}`,
"Content-Type": "application/json",
diff --git a/Extensions/combined/src/premiumAnalytics/theme.js b/Extensions/combined/src/premiumAnalytics/theme.js
index 95d9787..593572c 100644
--- a/Extensions/combined/src/premiumAnalytics/theme.js
+++ b/Extensions/combined/src/premiumAnalytics/theme.js
@@ -1,9 +1,15 @@
export function getTextColor() {
- return getComputedStyle(document.documentElement).getPropertyValue("--yt-spec-text-primary").trim() || "#ffffff";
+ return (
+ getComputedStyle(document.documentElement).getPropertyValue("--yt-spec-text-primary").trim() ||
+ (isDarkTheme() ? "#ffffff" : "#0f0f0f")
+ );
}
export function getMutedTextColor() {
- return getComputedStyle(document.documentElement).getPropertyValue("--yt-spec-text-secondary").trim() || "#b3b3b3";
+ return (
+ getComputedStyle(document.documentElement).getPropertyValue("--yt-spec-text-secondary").trim() ||
+ (isDarkTheme() ? "#b3b3b3" : "#606060")
+ );
}
export function getBorderColor(alpha = 0.15) {
@@ -25,48 +31,21 @@ export function getHoverFillColor(lightAlpha = 0.18, darkAlpha = 0.18) {
export function isDarkTheme() {
const root = document.documentElement;
- if (root.hasAttribute("dark") && !root.hasAttribute("light")) {
- return true;
- }
- if (root.getAttribute("dark") === "false") {
- return false;
- }
- const colorValue = getComputedStyle(root).getPropertyValue("--yt-spec-text-primary").trim();
- const color = parseColor(colorValue);
- if (color) {
- const luminance = (0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b) / 255;
- return luminance > 0.5;
- }
- return true;
+ return root.hasAttribute("dark") && root.getAttribute("dark") !== "false" && !root.hasAttribute("light");
}
-function parseColor(value) {
- if (!value) {
- return null;
- }
- const trimmed = value.trim();
- if (trimmed.startsWith('#')) {
- const hex = trimmed.slice(1);
- if (hex.length === 3) {
- const r = parseInt(hex[0] + hex[0], 16);
- const g = parseInt(hex[1] + hex[1], 16);
- const b = parseInt(hex[2] + hex[2], 16);
- return { r, g, b };
- }
- if (hex.length === 6) {
- const r = parseInt(hex.slice(0, 2), 16);
- const g = parseInt(hex.slice(2, 4), 16);
- const b = parseInt(hex.slice(4, 6), 16);
- return { r, g, b };
- }
- return null;
- }
- const rgbMatch = trimmed.match(/rgba?\(([^)]+)\)/i);
- if (rgbMatch) {
- const parts = rgbMatch[1].split(',').map((part) => parseFloat(part.trim()));
- if (parts.length >= 3) {
- return { r: parts[0], g: parts[1], b: parts[2] };
- }
- }
- return null;
+export function observeTheme(onChange) {
+ const colors = () => `${isDarkTheme()}:${getTextColor()}:${getMutedTextColor()}`;
+ let previous = colors();
+ const observer = new MutationObserver(() => {
+ const next = colors();
+ if (next === previous) return;
+ previous = next;
+ onChange();
+ });
+ observer.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ["dark", "light", "style", "class"],
+ });
+ return observer;
}
diff --git a/Extensions/combined/src/premiumAnalytics/theme.spec.js b/Extensions/combined/src/premiumAnalytics/theme.spec.js
index 3904e5b..ca6bd73 100644
--- a/Extensions/combined/src/premiumAnalytics/theme.spec.js
+++ b/Extensions/combined/src/premiumAnalytics/theme.spec.js
@@ -2,12 +2,22 @@
* @jest-environment jsdom
*/
-import { getTextColor, getMutedTextColor, getBorderColor, getSurfaceColor, getHoverFillColor, isDarkTheme } from "./theme";
+import {
+ getTextColor,
+ getMutedTextColor,
+ getBorderColor,
+ getSurfaceColor,
+ getHoverFillColor,
+ isDarkTheme,
+ observeTheme,
+} from "./theme";
describe("premiumAnalytics.theme", () => {
const originalGetComputedStyle = window.getComputedStyle;
beforeEach(() => {
+ document.documentElement.setAttribute("dark", "");
+ document.documentElement.removeAttribute("light");
window.getComputedStyle = jest.fn().mockImplementation(() => ({
getPropertyValue: (name) => {
if (name === "--yt-spec-text-primary") return " #fefefe ";
@@ -19,6 +29,9 @@ describe("premiumAnalytics.theme", () => {
afterEach(() => {
window.getComputedStyle = originalGetComputedStyle;
+ document.documentElement.removeAttribute("dark");
+ document.documentElement.removeAttribute("light");
+ document.documentElement.removeAttribute("style");
});
it("returns trimmed primary text color", () => {
@@ -34,8 +47,47 @@ describe("premiumAnalytics.theme", () => {
expect(getBorderColor()).toBe("rgba(255,255,255,0.15)");
});
+ it.each([null, "false"])("uses light fallbacks without an enabled dark marker (%s)", (dark) => {
+ if (dark === null) document.documentElement.removeAttribute("dark");
+ else document.documentElement.setAttribute("dark", dark);
+ window.getComputedStyle.mockReturnValue({ getPropertyValue: () => "" });
+ expect(isDarkTheme()).toBe(false);
+ expect(getTextColor()).toBe("#0f0f0f");
+ expect(getMutedTextColor()).toBe("#606060");
+ expect(getBorderColor()).toBe("rgba(15,23,42,0.15)");
+ });
+
+ it("uses dark fallbacks only while the host dark marker is enabled", () => {
+ window.getComputedStyle.mockReturnValue({ getPropertyValue: () => "" });
+ expect(isDarkTheme()).toBe(true);
+ expect(getTextColor()).toBe("#ffffff");
+ expect(getMutedTextColor()).toBe("#b3b3b3");
+ document.documentElement.setAttribute("light", "");
+ expect(isDarkTheme()).toBe(false);
+ expect(getTextColor()).toBe("#0f0f0f");
+ });
+
+ it("notifies for effective theme colors and stops observing after disconnect", async () => {
+ window.getComputedStyle.mockReturnValue({ getPropertyValue: () => "" });
+ const changed = jest.fn();
+ const observer = observeTheme(changed);
+ document.documentElement.style.setProperty("--unrelated", "1");
+ await Promise.resolve();
+ expect(changed).not.toHaveBeenCalled();
+ document.documentElement.removeAttribute("dark");
+ await Promise.resolve();
+ expect(changed).toHaveBeenCalledTimes(1);
+ document.documentElement.setAttribute("dark", "");
+ await Promise.resolve();
+ expect(changed).toHaveBeenCalledTimes(2);
+ observer.disconnect();
+ document.documentElement.removeAttribute("dark");
+ await Promise.resolve();
+ expect(changed).toHaveBeenCalledTimes(2);
+ });
+
it("derives surface colors based on theme", () => {
- document.documentElement.setAttribute('dark', '');
+ document.documentElement.setAttribute("dark", "");
window.getComputedStyle.mockImplementation(() => ({
getPropertyValue: (name) => {
if (name === "--yt-spec-text-primary") return "#fefefe";
@@ -44,11 +96,11 @@ describe("premiumAnalytics.theme", () => {
},
}));
expect(isDarkTheme()).toBe(true);
- expect(getSurfaceColor()).toBe('rgba(255,255,255,0.02)');
- expect(getHoverFillColor()).toBe('rgba(255,255,255,0.18)');
+ expect(getSurfaceColor()).toBe("rgba(255,255,255,0.02)");
+ expect(getHoverFillColor()).toBe("rgba(255,255,255,0.18)");
- document.documentElement.removeAttribute('dark');
- document.documentElement.setAttribute('light', '');
+ document.documentElement.removeAttribute("dark");
+ document.documentElement.setAttribute("light", "");
window.getComputedStyle.mockImplementation(() => ({
getPropertyValue: (name) => {
if (name === "--yt-spec-text-primary") return "#111111";
@@ -57,9 +109,9 @@ describe("premiumAnalytics.theme", () => {
},
}));
expect(isDarkTheme()).toBe(false);
- expect(getBorderColor(0.2)).toBe('rgba(15,23,42,0.2)');
- expect(getSurfaceColor()).toBe('rgba(15,23,42,0.06)');
- expect(getHoverFillColor()).toBe('rgba(15,23,42,0.18)');
- document.documentElement.removeAttribute('light');
+ expect(getBorderColor(0.2)).toBe("rgba(15,23,42,0.2)");
+ expect(getSurfaceColor()).toBe("rgba(15,23,42,0.06)");
+ expect(getHoverFillColor()).toBe("rgba(15,23,42,0.18)");
+ document.documentElement.removeAttribute("light");
});
});
diff --git a/Extensions/combined/src/private-browsing.js b/Extensions/combined/src/private-browsing.js
new file mode 100644
index 0000000..e10bd52
--- /dev/null
+++ b/Extensions/combined/src/private-browsing.js
@@ -0,0 +1,28 @@
+function extensionApi() {
+ return typeof browser !== "undefined" ? browser : globalThis.chrome;
+}
+
+function isFirefoxExtension(api = extensionApi()) {
+ return Boolean(api?.runtime?.getManifest?.()?.browser_specific_settings?.gecko);
+}
+
+function isPrivateBrowsingContext() {
+ const api = extensionApi();
+ return isFirefoxExtension(api) && api?.extension?.inIncognitoContext === true;
+}
+
+function isNormalMessageSender(api, sender) {
+ if (!isFirefoxExtension(api)) return true;
+ if (sender?.id !== api.runtime.id) return false;
+ if (sender.tab) return sender.tab.incognito === false;
+ try {
+ const url = new URL(sender.url);
+ if (!url.searchParams.get("rydContext")) return false;
+ const views = api.extension.getViews().filter((view) => view.location.href === sender.url);
+ return views.length === 1 && views[0].browser?.extension?.inIncognitoContext === false;
+ } catch (_) {
+ return false;
+ }
+}
+
+export { isFirefoxExtension, isPrivateBrowsingContext, isNormalMessageSender };
diff --git a/Extensions/combined/src/private-browsing.spec.js b/Extensions/combined/src/private-browsing.spec.js
new file mode 100644
index 0000000..02e3ef4
--- /dev/null
+++ b/Extensions/combined/src/private-browsing.spec.js
@@ -0,0 +1,44 @@
+import { isFirefoxExtension, isPrivateBrowsingContext, isNormalMessageSender } from "./private-browsing";
+
+afterEach(() => {
+ delete global.browser;
+ delete global.chrome;
+});
+
+test("background routing uses browser tab privacy metadata", () => {
+ const api = { runtime: { id: "fixture", getManifest: () => ({ browser_specific_settings: { gecko: {} } }) } };
+ expect(isNormalMessageSender(api, { id: "fixture", tab: { incognito: false } })).toBe(true);
+ expect(isNormalMessageSender(api, { id: "fixture", tab: { incognito: true } })).toBe(false);
+ expect(isNormalMessageSender(api, { id: "fixture", tab: {} })).toBe(false);
+ expect(isNormalMessageSender(api, { id: "foreign", tab: { incognito: false } })).toBe(false);
+});
+
+test("Firefox popup routing requires a distinct live normal view", () => {
+ const url = "moz-extension://fixture/popup.html?rydContext=synthetic-view";
+ const view = { location: { href: url }, browser: { extension: { inIncognitoContext: false } } };
+ const api = {
+ runtime: { id: "fixture", getManifest: () => ({ browser_specific_settings: { gecko: {} } }) },
+ extension: { getViews: jest.fn(() => [view]) },
+ };
+ const sender = { id: "fixture", url };
+ expect(isNormalMessageSender(api, sender)).toBe(true);
+ expect(isNormalMessageSender(api, { ...sender, url: "moz-extension://fixture/popup.html" })).toBe(false);
+ view.browser.extension.inIncognitoContext = true;
+ expect(isNormalMessageSender(api, sender)).toBe(false);
+ api.extension.getViews.mockReturnValue([]);
+ expect(isNormalMessageSender(api, sender)).toBe(false);
+});
+
+test("private display mode is limited to Firefox private contexts", () => {
+ global.browser = {
+ runtime: { getManifest: () => ({ browser_specific_settings: { gecko: {} } }) },
+ extension: { inIncognitoContext: true },
+ };
+ expect(isFirefoxExtension()).toBe(true);
+ expect(isPrivateBrowsingContext()).toBe(true);
+ browser.extension.inIncognitoContext = false;
+ expect(isPrivateBrowsingContext()).toBe(false);
+ browser.extension.inIncognitoContext = true;
+ browser.runtime.getManifest = () => ({ manifest_version: 3 });
+ expect(isPrivateBrowsingContext()).toBe(false);
+});
diff --git a/Extensions/combined/src/reaction-presentation.spec.js b/Extensions/combined/src/reaction-presentation.spec.js
index ff3f072..5665f01 100644
--- a/Extensions/combined/src/reaction-presentation.spec.js
+++ b/Extensions/combined/src/reaction-presentation.spec.js
@@ -13,7 +13,7 @@ import {
setSyntheticShortsDislikeEnabled,
__RewireAPI__ as buttonsRewire,
} from "./buttons";
-import { addLikeDislikeEventListener, storageChangeHandler } from "./events";
+import { addLikeDislikeEventListener, storageChangeHandler, __RewireAPI__ as eventsRewire } from "./events";
import {
extConfig,
isVideoDisliked,
@@ -91,6 +91,8 @@ beforeEach(() => {
coloredThumbs: false,
colorTheme: "classic",
disableVoteSubmission: true,
+ showTooltipPercentage: false,
+ tooltipPercentageMode: "dash_like",
});
});
@@ -448,3 +450,176 @@ test("synthetic Shorts Dislike clears the native Like without activating a prepe
expect(dislike.querySelector("button").getAttribute("aria-pressed")).toBe("true");
expect(storedData).toMatchObject({ previousState: DISLIKED_STATE, likes: 729, dislikes: 13 });
});
+
+describe("live tooltip preferences", () => {
+ function mountTooltip({ enabled = false, mode = "dash_like", nested = false } = {}) {
+ mount({ likes: 260 });
+ storedData.dislikes = 4;
+ extConfig.showTooltipPercentage = enabled;
+ extConfig.tooltipPercentageMode = mode;
+ createRateBar.mockImplementation(renderRateBar);
+ renderRateBar(storedData.likes, storedData.dislikes);
+ const bar = document.querySelector("#ryd-bar-container");
+ bar.getBoundingClientRect = () => ({ top: 0, left: 0, right: 200, bottom: 2, width: 200, height: 2 });
+ const tooltip = document.querySelector("#ryd-dislike-tooltip");
+ if (nested) {
+ const content = document.createElement("div");
+ content.id = "tooltip";
+ content.innerHTML = tooltip.innerHTML;
+ tooltip.replaceChildren(content);
+ }
+ createRateBar.mockClear();
+ return tooltip;
+ }
+
+ const tooltipText = (tooltip) => tooltip.textContent.replace(/\s+/g, " ").trim();
+
+ test.each([
+ ["dash_like", "260 / 4 - 98.5%"],
+ ["dash_dislike", "260 / 4 - 1.5%"],
+ ["both", "98.5% / 1.5%"],
+ ["only_like", "98.5%"],
+ ["only_dislike", "1.5%"],
+ ])("updates an existing nested tooltip when sync mode changes to %s", (mode, expected) => {
+ const tooltip = mountTooltip({ enabled: true, mode: mode === "dash_like" ? "both" : "dash_like", nested: true });
+ const beforeCounts = { ...storedData };
+
+ storageChangeHandler({ tooltipPercentageMode: { newValue: mode } }, "sync");
+
+ expect(tooltipText(tooltip)).toBe(expected);
+ expect(document.querySelector("#ryd-dislike-tooltip")).toBe(tooltip);
+ expect(storedData).toEqual(beforeCounts);
+ });
+
+ test.each([true, false])("updates the existing tooltip when percentages become %s", (enabled) => {
+ const tooltip = mountTooltip({ enabled: !enabled, mode: "only_dislike" });
+
+ storageChangeHandler({ showTooltipPercentage: { newValue: enabled } }, "sync");
+
+ expect(tooltipText(tooltip)).toBe(enabled ? "1.5%" : "260 / 4");
+ expect(document.querySelector("#ryd-dislike-tooltip")).toBe(tooltip);
+ });
+
+ test("applies a combined preference event before repainting once", () => {
+ const tooltip = mountTooltip();
+
+ storageChangeHandler(
+ { showTooltipPercentage: { newValue: true }, tooltipPercentageMode: { newValue: "only_dislike" } },
+ "sync",
+ );
+
+ expect(tooltipText(tooltip)).toBe("1.5%");
+ expect(createRateBar).toHaveBeenCalledTimes(1);
+ });
+
+ test("removing tooltip settings restores their existing defaults", () => {
+ const tooltip = mountTooltip({ enabled: true, mode: "only_dislike" });
+
+ storageChangeHandler(
+ { showTooltipPercentage: { oldValue: true }, tooltipPercentageMode: { oldValue: "only_dislike" } },
+ "sync",
+ );
+ expect(tooltipText(tooltip)).toBe("260 / 4");
+ storageChangeHandler({ showTooltipPercentage: { newValue: true } }, "sync");
+ expect(tooltipText(tooltip)).toBe("260 / 4 - 98.5%");
+ });
+
+ test.each(["local", "session", "managed", undefined])("ignores tooltip preferences outside sync (%s)", (area) => {
+ const tooltip = mountTooltip();
+
+ storageChangeHandler(
+ { showTooltipPercentage: { newValue: true }, tooltipPercentageMode: { newValue: "only_dislike" } },
+ area,
+ );
+
+ expect(tooltipText(tooltip)).toBe("260 / 4");
+ expect(extConfig.showTooltipPercentage).toBe(false);
+ expect(extConfig.tooltipPercentageMode).toBe("dash_like");
+ expect(createRateBar).not.toHaveBeenCalled();
+ });
+
+ test.each(["/watch?v=zyxwvutsrqp", "/feed/subscriptions"])(
+ "retains preferences without repainting outgoing cached counts on %s",
+ (url) => {
+ const tooltip = mountTooltip();
+ history.replaceState({}, "", url);
+
+ storageChangeHandler(
+ { showTooltipPercentage: { newValue: true }, tooltipPercentageMode: { newValue: "only_dislike" } },
+ "sync",
+ );
+
+ expect(extConfig.showTooltipPercentage).toBe(true);
+ expect(extConfig.tooltipPercentageMode).toBe("only_dislike");
+ expect(tooltipText(tooltip)).toBe("260 / 4");
+ expect(createRateBar).not.toHaveBeenCalled();
+ },
+ );
+
+ test("private tabs retain their existing isolation from normal tooltip preferences", () => {
+ const tooltip = mountTooltip();
+ const restore = eventsRewire.__set__("isPrivateBrowsingContext", () => true);
+ try {
+ storageChangeHandler(
+ { showTooltipPercentage: { newValue: true }, tooltipPercentageMode: { newValue: "only_dislike" } },
+ "sync",
+ );
+ expect(extConfig.showTooltipPercentage).toBe(false);
+ expect(extConfig.tooltipPercentageMode).toBe("dash_like");
+ expect(tooltipText(tooltip)).toBe("260 / 4");
+ expect(createRateBar).not.toHaveBeenCalled();
+ } finally {
+ restore();
+ }
+ });
+});
+
+describe("live dislike number format", () => {
+ test.each([
+ ["compactShort", false, "1.3K", "11299"],
+ ["compactLong", false, "1.3 thousand", "11299"],
+ ["standard", false, "1,335", "11299"],
+ ["compactShort", true, "1.3K", "11K"],
+ ["compactLong", true, "1.3 thousand", "11 thousand"],
+ ["standard", true, "1,335", "11,299"],
+ ])("formats cached dislikes as %s with native Like reformatting=%s", (mode, reformatLikes, dislikes, likes) => {
+ document.documentElement.lang = "en";
+ const controls = mount({ likes: 11299 });
+ const nativeLikeText = getLikeTextContainer().firstChild;
+ storedData.dislikes = 1335;
+ extConfig.numberDisplayReformatLikes = reformatLikes;
+ extConfig.numberDisplayFormat = mode === "standard" ? "compactShort" : "standard";
+ controls.dislikeTextContainer.innerText = mode === "standard" ? "1.3K" : "1,335";
+ const readVotes = jest.fn();
+ const restore = stateRewire.__set__("requestVoteData", readVotes);
+ try {
+ storageChangeHandler({ numberDisplayFormat: { newValue: mode } }, "sync");
+
+ expect(controls.dislikeTextContainer.innerText).toBe(dislikes);
+ expect(getLikeTextContainer().firstChild).toBe(nativeLikeText);
+ expect(nativeLikeText.data).toBe(likes);
+ expect(storedData.likes).toBe(11299);
+ expect(storedData.dislikes).toBe(1335);
+ expect(readVotes).not.toHaveBeenCalled();
+ } finally {
+ restore();
+ }
+ });
+
+ test.each(["/watch?v=zyxwvutsrqp", "/feed/subscriptions"])(
+ "does not format outgoing cached dislikes on %s",
+ (url) => {
+ const controls = mount({ likes: 11299 });
+ storedData.dislikes = 1335;
+ controls.dislikeTextContainer.innerText = "1,335";
+ history.replaceState({}, "", url);
+ createRateBar.mockClear();
+
+ storageChangeHandler({ numberDisplayFormat: { newValue: "compactLong" } }, "sync");
+
+ expect(extConfig.numberDisplayFormat).toBe("compactLong");
+ expect(controls.dislikeTextContainer.innerText).toBe("1,335");
+ expect(createRateBar).not.toHaveBeenCalled();
+ },
+ );
+});
diff --git a/Extensions/combined/src/state.js b/Extensions/combined/src/state.js
index 4068532..1aeb643 100644
--- a/Extensions/combined/src/state.js
+++ b/Extensions/combined/src/state.js
@@ -1,3 +1,4 @@
+import { logger } from "./logging";
import {
getButtonControls,
getLikeButton,
@@ -32,6 +33,7 @@ import { createBrowserSyntheticDislikeStore } from "./vote-client-adapter";
import { requestVoteData } from "./vote-data-request";
import { fetchWithCoreConsent } from "./core-data-consent";
import { normalizeCustomColors } from "../../common/color-themes";
+import { isPrivateBrowsingContext } from "./private-browsing";
const DEFAULT_SELECTORS = {
dislikeTextContainer: [
@@ -183,6 +185,7 @@ function isRoundedDesign() {
let syntheticDislikeStore = null;
function getSyntheticDislikeStore() {
+ if (isPrivateBrowsingContext()) return null;
if (syntheticDislikeStore) return syntheticDislikeStore;
const browserApi = getBrowser();
if (!browserApi?.storage?.local) return null;
@@ -199,7 +202,7 @@ async function readSyntheticShortsDislikeState(videoId) {
try {
return await store.isDisliked(videoId);
} catch (error) {
- console.debug("Could not restore the synthetic Shorts Dislike state.", error?.message ?? error);
+ logger.debug("Could not restore the synthetic Shorts Dislike state.", error?.message ?? error);
return false;
}
}
@@ -333,7 +336,7 @@ function refreshReactionPresentation() {
}
function setDislikes(dislikesCount) {
- console.log(`SET dislikes ${dislikesCount}`);
+ if (!isPrivateBrowsingContext()) logger.log(`SET dislikes ${dislikesCount}`);
const controls = getButtonControls();
const _container = getDislikeTextContainer(controls);
@@ -344,7 +347,7 @@ function setDislikes(dislikesCount) {
if (!isLikesDisabled(controls)) {
_dislikeText = dislikesCount;
} else {
- console.log("likes count disabled by creator");
+ logger.log("likes count disabled by creator");
_dislikeText = localize("TextLikesDisabled");
}
@@ -425,7 +428,7 @@ async function setState(storedData) {
}
const videoId = getVideoId(window.location.href);
const dislikeButton = getDislikeButton();
- if (isSyntheticShortsDislike(dislikeButton)) {
+ if (isSyntheticShortsDislike(dislikeButton) && !isPrivateBrowsingContext()) {
const storedDisliked = await readSyntheticShortsDislikeState(videoId);
if (getVideoId(window.location.href) !== videoId) return;
const liked = isVideoLiked();
@@ -435,7 +438,7 @@ async function setState(storedData) {
} else {
storedData.previousState = isVideoDisliked() ? DISLIKED_STATE : isVideoLiked() ? LIKED_STATE : NEUTRAL_STATE;
}
- console.log("Video is loaded. Adding buttons...");
+ logger.log("Video is loaded. Adding buttons...");
const likeCount = getLikeCountFromButton() || null;
let response;
@@ -446,8 +449,10 @@ async function setState(storedData) {
displayError(error, videoId);
return;
}
- console.log("response from api:");
- console.log(JSON.stringify(response));
+ if (!isPrivateBrowsingContext()) {
+ logger.log("response from api:");
+ logger.log(JSON.stringify(response));
+ }
if (getVideoId(window.location.href) !== videoId) {
return;
}
@@ -520,6 +525,11 @@ function clearRenderedVoteState(controls = getButtonControls()) {
}
async function initExtConfig() {
+ if (isPrivateBrowsingContext()) {
+ await initializePrivateDisplayPreferences();
+ await initializeSelectors();
+ return;
+ }
initializeDisableVoteSubmission();
initializeDisableLogging();
initializeColoredThumbs();
@@ -534,8 +544,39 @@ async function initExtConfig() {
await initializeSelectors();
}
+function initializePrivateDisplayPreferences() {
+ const preferences = [
+ "coloredThumbs",
+ "coloredBar",
+ "colorTheme",
+ "customColors",
+ "numberDisplayFormat",
+ "showTooltipPercentage",
+ "tooltipPercentageMode",
+ "numberDisplayReformatLikes",
+ HIDE_CLUTTER_BUTTONS_STORAGE_KEY,
+ ];
+ extConfig.disableLogging = true;
+ initializeLogging();
+ extConfig.hidePremiumTeaser = true;
+ return new Promise((resolve) => {
+ getBrowser().storage.sync.get(preferences, (values = {}) => {
+ for (const key of preferences) {
+ if (values[key] !== undefined) extConfig[key] = values[key];
+ }
+ extConfig.colorTheme = values.colorTheme || "classic";
+ extConfig.customColors = normalizeCustomColors(values.customColors);
+ extConfig.hideClutterButtons = publishHideClutterButtons(values[HIDE_CLUTTER_BUTTONS_STORAGE_KEY]);
+ resolve();
+ });
+ });
+}
+
async function initializeSelectors() {
const controller = new AbortController();
+ const privateContext = isPrivateBrowsingContext();
+ const abortOnPageHide = () => controller.abort();
+ if (privateContext) window.addEventListener("pagehide", abortOnPageHide, { once: true });
const timeout = setTimeout(() => controller.abort(), SELECTOR_REQUEST_TIMEOUT_MS);
try {
const response = await fetchWithCoreConsent(getApiEndpoint("/configs/selectors"), {
@@ -544,20 +585,23 @@ async function initializeSelectors() {
Accept: "application/json",
},
signal: controller.signal,
+ ...(privateContext ? { credentials: "omit", cache: "no-store" } : {}),
});
if (!response.ok) {
throw new Error(`Selector request failed with HTTP ${response.status}`);
}
const result = await response.json();
+ if (privateContext && controller.signal.aborted) return false;
extConfig.selectors = mergeConfig(DEFAULT_SELECTORS, result);
- console.log(result);
+ if (!isPrivateBrowsingContext()) logger.log(result);
return true;
} catch (error) {
extConfig.selectors = cloneConfig(DEFAULT_SELECTORS);
- console.debug("Remote selectors unavailable; using bundled selectors.", error?.name ?? error);
+ logger.debug("Remote selectors unavailable; using bundled selectors.", error?.name ?? error);
return false;
} finally {
clearTimeout(timeout);
+ if (privateContext) window.removeEventListener("pagehide", abortOnPageHide);
}
}
@@ -571,16 +615,22 @@ function initializeDisableVoteSubmission() {
});
}
+let loggingPreferenceRevision = 0;
+
+function updateDisableLogging(value) {
+ loggingPreferenceRevision += 1;
+ extConfig.disableLogging = value ?? true;
+ initializeLogging();
+}
+
function initializeDisableLogging() {
+ const revision = loggingPreferenceRevision;
getBrowser().storage.sync.get(["disableLogging"], (res) => {
+ if (revision !== loggingPreferenceRevision) return;
if (res.disableLogging === undefined) {
getBrowser().storage.sync.set({ disableLogging: true });
- extConfig.disableLogging = true;
- } else {
- extConfig.disableLogging = res.disableLogging;
}
- // Initialize console methods based on logging config
- initializeLogging();
+ updateDisableLogging(res.disableLogging);
});
}
@@ -688,7 +738,7 @@ function initializeHideClutterButtons() {
const storedValue = res[HIDE_CLUTTER_BUTTONS_STORAGE_KEY];
const normalized = normalizeHideClutterButtons(storedValue);
extConfig.hideClutterButtons = publishHideClutterButtons(normalized);
- if (storedValue === undefined) {
+ if (storedValue === undefined && !isPrivateBrowsingContext()) {
storage.set({ [HIDE_CLUTTER_BUTTONS_STORAGE_KEY]: false });
}
resolve(normalized);
@@ -723,4 +773,5 @@ export {
clearRenderedVoteState,
persistSyntheticShortsDislikeState,
initializeHideClutterButtons,
+ updateDisableLogging,
};
diff --git a/Extensions/combined/src/state.spec.js b/Extensions/combined/src/state.spec.js
index fdd5333..a18aec5 100644
--- a/Extensions/combined/src/state.spec.js
+++ b/Extensions/combined/src/state.spec.js
@@ -82,12 +82,15 @@ import {
clearRenderedVoteState,
extConfig,
getLikeCountFromButton,
+ initExtConfig,
initializeHideClutterButtons,
initializeSelectors,
+ updateDisableLogging,
restoreCurrentState,
setDislikes,
setState,
storedData,
+ persistSyntheticShortsDislikeState,
__RewireAPI__ as stateRewire,
} from "./state";
@@ -127,6 +130,8 @@ afterEach(() => {
stateRewire.__ResetDependency__("nativeLikeCounts");
stateRewire.__ResetDependency__("isMobile");
stateRewire.__ResetDependency__("syntheticDislikeStore");
+ stateRewire.__ResetDependency__("isPrivateBrowsingContext");
+ stateRewire.__ResetDependency__("initializeLogging");
});
afterAll(() => {
@@ -135,6 +140,49 @@ afterAll(() => {
delete globalThis.__mockControlsUnavailable;
});
+test.each([true, false, undefined])("initializes the logging preference (%s)", (value) => {
+ const initializeLogging = jest.fn();
+ stateRewire.__set__("initializeLogging", initializeLogging);
+ const set = jest.fn();
+ globalThis.__mockBrowser = {
+ storage: { sync: { get: (_keys, callback) => callback({ disableLogging: value }), set } },
+ };
+
+ stateRewire.__get__("initializeDisableLogging")();
+
+ expect(extConfig.disableLogging).toBe(value ?? true);
+ expect(initializeLogging).toHaveBeenCalledTimes(1);
+ if (value === undefined) expect(set).toHaveBeenCalledWith({ disableLogging: true });
+ else expect(set).not.toHaveBeenCalled();
+});
+
+test.each([true, false, undefined])("a late logging preference read cannot override a live update (%s)", (value) => {
+ const initializeLogging = jest.fn();
+ stateRewire.__set__("initializeLogging", initializeLogging);
+ let finishRead;
+ const set = jest.fn();
+ globalThis.__mockBrowser = {
+ storage: { sync: { get: (_keys, callback) => (finishRead = callback), set } },
+ };
+ stateRewire.__get__("initializeDisableLogging")();
+
+ updateDisableLogging(false);
+ finishRead({ disableLogging: value });
+
+ expect(extConfig.disableLogging).toBe(false);
+ expect(initializeLogging).toHaveBeenCalledTimes(1);
+ expect(set).not.toHaveBeenCalled();
+});
+
+test("removing the logging preference restores the default suppression", () => {
+ const initializeLogging = jest.fn();
+ stateRewire.__set__("initializeLogging", initializeLogging);
+ updateDisableLogging(false);
+ updateDisableLogging(undefined);
+ expect(extConfig.disableLogging).toBe(true);
+ expect(initializeLogging).toHaveBeenCalledTimes(2);
+});
+
test("hide-clutter initialization persists and publishes the default-off value", async () => {
const set = jest.fn();
globalThis.__mockBrowser = {
@@ -535,3 +583,176 @@ test("falls back to bundled selectors when the remote selector request never set
jest.useRealTimers();
}
});
+
+describe("private count display", () => {
+ beforeEach(() => {
+ stateRewire.__set__("isPrivateBrowsingContext", () => true);
+ });
+
+ test.each([false, true])("reads only display preferences without default writes (saved=%s)", async (saved) => {
+ const initializeLogging = jest.fn();
+ stateRewire.__set__("initializeLogging", initializeLogging);
+ const preferences = saved
+ ? {
+ coloredThumbs: true,
+ coloredBar: true,
+ colorTheme: "custom",
+ customColors: { light: { like: "#123456", dislike: "#654321" } },
+ numberDisplayFormat: "standard",
+ showTooltipPercentage: true,
+ tooltipPercentageMode: "percentage",
+ numberDisplayReformatLikes: true,
+ hideClutterButtons: true,
+ disableLogging: false,
+ patreonSessionToken: "synthetic-normal-session",
+ }
+ : {};
+ Object.assign(extConfig, {
+ coloredThumbs: false,
+ coloredBar: false,
+ colorTheme: "classic",
+ numberDisplayFormat: "compactShort",
+ showTooltipPercentage: false,
+ tooltipPercentageMode: "dash_like",
+ numberDisplayReformatLikes: false,
+ hideClutterButtons: false,
+ });
+ const storage = {
+ sync: {
+ get: jest.fn((_keys, callback) => callback(preferences)),
+ set: jest.fn(),
+ },
+ local: { get: jest.fn(), set: jest.fn(), remove: jest.fn() },
+ onChanged: { addListener: jest.fn(), removeListener: jest.fn() },
+ };
+ globalThis.__mockBrowser = { storage };
+ fetch.mockResolvedValueOnce({ ok: true, json: async () => ({}) });
+ const consoleLog = console.log;
+ const consoleDebug = console.debug;
+
+ await initExtConfig();
+
+ expect(storage.sync.get).toHaveBeenCalledTimes(1);
+ expect(storage.sync.get).toHaveBeenCalledWith(
+ [
+ "coloredThumbs",
+ "coloredBar",
+ "colorTheme",
+ "customColors",
+ "numberDisplayFormat",
+ "showTooltipPercentage",
+ "tooltipPercentageMode",
+ "numberDisplayReformatLikes",
+ "hideClutterButtons",
+ ],
+ expect.any(Function),
+ );
+ expect(storage.sync.set).not.toHaveBeenCalled();
+ expect(storage.local.get).not.toHaveBeenCalled();
+ expect(storage.local.set).not.toHaveBeenCalled();
+ expect(storage.local.remove).not.toHaveBeenCalled();
+ expect(storage.onChanged.addListener).not.toHaveBeenCalled();
+ expect(initializeLogging).toHaveBeenCalledTimes(1);
+ expect(console.log).toBe(consoleLog);
+ expect(console.debug).toBe(consoleDebug);
+ expect(extConfig.coloredBar).toBe(saved);
+ expect(extConfig.numberDisplayFormat).toBe(saved ? "standard" : "compactShort");
+ expect(extConfig.customColors.light.like).toBe(saved ? "#123456" : "#007600");
+ expect(extConfig.disableLogging).toBe(true);
+ expect(extConfig).not.toHaveProperty("patreonSessionToken");
+ expect(fetch).toHaveBeenCalledWith(
+ "https://example.test/configs/selectors",
+ expect.objectContaining({ method: "GET", credentials: "omit", cache: "no-store" }),
+ );
+ });
+
+ test("private initialization suppresses its own count diagnostics without replacing console methods", async () => {
+ const { logger, setLoggingDisabled } = jest.requireActual("./logging");
+ const log = jest.spyOn(console, "log").mockImplementation(() => {});
+ const debug = jest.spyOn(console, "debug").mockImplementation(() => {});
+ const ownLog = jest.spyOn(logger, "log");
+ const initializeLogging = jest.fn(() => setLoggingDisabled(extConfig.disableLogging));
+ stateRewire.__set__("initializeLogging", initializeLogging);
+ const storage = {
+ sync: {
+ get: jest.fn((_keys, callback) => callback({ disableLogging: false })),
+ set: jest.fn(),
+ },
+ };
+ globalThis.__mockBrowser = { storage };
+ fetch
+ .mockResolvedValueOnce({ ok: true, json: async () => ({}) })
+ .mockResolvedValueOnce({ ok: true, json: async () => ({ dislikes: 65, likes: 100 }) });
+ extConfig.disableLogging = false;
+ setLoggingDisabled(false);
+
+ try {
+ await initExtConfig();
+ await setState(storedData);
+ logger.debug("Private count diagnostic");
+
+ expect(initializeLogging).toHaveBeenCalledTimes(1);
+ expect(extConfig.disableLogging).toBe(true);
+ expect(ownLog).toHaveBeenCalledWith("Video is loaded. Adding buttons...");
+ expect(log).not.toHaveBeenCalled();
+ expect(debug).not.toHaveBeenCalled();
+ expect(console.log).toBe(log);
+ expect(console.debug).toBe(debug);
+ expect(storage.sync.get.mock.calls[0][0]).not.toContain("disableLogging");
+ expect(storage.sync.set).not.toHaveBeenCalled();
+ } finally {
+ setLoggingDisabled(false);
+ ownLog.mockRestore();
+ log.mockRestore();
+ debug.mockRestore();
+ }
+ });
+
+ test("never restores or writes synthetic Shorts history, including direct persistence calls", async () => {
+ const store = { isDisliked: jest.fn(async () => true), setDisliked: jest.fn(async () => undefined) };
+ stateRewire.__set__("syntheticDislikeStore", store);
+ stateRewire.__set__("isMobile", () => false);
+ getDislikeButton().synthetic = true;
+ fetch.mockResolvedValueOnce({ ok: true, json: async () => ({ dislikes: 65, likes: 100 }) });
+
+ await setState(storedData);
+ await persistSyntheticShortsDislikeState("abcdefghijk", true);
+ await persistSyntheticShortsDislikeState("abcdefghijk", false);
+
+ expect(store.isDisliked).not.toHaveBeenCalled();
+ expect(store.setDisliked).not.toHaveBeenCalled();
+ expect(storedData).toMatchObject({ dislikes: 65, previousState: "NEUTRAL_STATE", videoId: "abcdefghijk" });
+ expect(setSyntheticShortsDislikePressed).toHaveBeenLastCalledWith(false, getDislikeButton());
+ });
+
+ test("omits private response and count payloads from diagnostics", async () => {
+ const payload = { dislikes: 654321, likes: 100, videoId: "private-test-video", marker: "private-count-payload" };
+ const log = jest.spyOn(console, "log").mockImplementation(() => {});
+ fetch.mockResolvedValueOnce({ ok: true, json: async () => payload });
+ try {
+ await setState(storedData);
+ expect(JSON.stringify(log.mock.calls)).not.toMatch(/654321|private-test-video|private-count-payload/);
+ } finally {
+ log.mockRestore();
+ }
+ });
+
+ test("does not save a missing clutter preference when initialized directly", async () => {
+ const storage = { get: jest.fn((_keys, callback) => callback({})), set: jest.fn() };
+ globalThis.__mockBrowser = { storage: { sync: storage } };
+ await initializeHideClutterButtons();
+ expect(storage.set).not.toHaveBeenCalled();
+ });
+
+ test("discards a late private selector response after page exit", async () => {
+ const pending = deferred();
+ fetch.mockReturnValueOnce(pending.promise);
+ const initialization = initializeSelectors();
+ const signal = fetch.mock.calls[0][1].signal;
+ window.dispatchEvent(new Event("pagehide"));
+ expect(signal.aborted).toBe(true);
+ pending.resolve({ ok: true, json: async () => ({ privateSelectorMarker: true }) });
+ await expect(initialization).resolves.toBe(false);
+ expect(extConfig.selectors).not.toHaveProperty("privateSelectorMarker");
+ });
+});
diff --git a/Extensions/combined/src/utils.js b/Extensions/combined/src/utils.js
index 5636e49..8b82062 100644
--- a/Extensions/combined/src/utils.js
+++ b/Extensions/combined/src/utils.js
@@ -1,3 +1,4 @@
+import { logger, setLoggingDisabled } from "./logging";
import { extConfig, isShorts } from "./state";
import { getThemeColor, isYouTubeDarkTheme } from "../../common/color-themes";
@@ -33,7 +34,7 @@ function getNumberFormatter(optionSelect) {
?.getAttribute("href"),
)?.searchParams?.get("locale");
} catch {
- console.log("Cannot find browser locale. Use en as default for number formatting.");
+ logger.log("Cannot find browser locale. Use en as default for number formatting.");
userLocales = "en";
}
}
@@ -91,7 +92,7 @@ function getBrowser() {
} else if (typeof browser !== "undefined" && typeof browser.runtime !== "undefined") {
return browser;
} else {
- console.log("browser is not supported");
+ logger.log("browser is not supported");
return false;
}
}
@@ -242,22 +243,8 @@ function isVideoLoaded() {
return querySelector(videoLoadedSelectors.map((selector) => selector.replace("{videoId}", videoId))) !== undefined;
}
-const originalConsole = {
- log: console.log.bind(console),
- debug: console.debug.bind(console),
- info: console.info.bind(console),
- warn: console.warn.bind(console),
- error: console.error.bind(console),
-};
-
function initializeLogging() {
- if (extConfig.disableLogging) {
- console.log = () => {};
- console.debug = () => {};
- } else {
- console.log = originalConsole.log;
- console.debug = originalConsole.debug;
- }
+ setLoggingDisabled(extConfig.disableLogging);
}
function getColorFromTheme(voteIsLike) {
diff --git a/Extensions/combined/src/utils.spec.js b/Extensions/combined/src/utils.spec.js
index 53d64bc..379b86d 100644
--- a/Extensions/combined/src/utils.spec.js
+++ b/Extensions/combined/src/utils.spec.js
@@ -45,6 +45,7 @@ jest.mock("./state", () => {
});
import { extConfig, isShorts } from "./state";
+import { logger, setLoggingDisabled } from "./logging";
import {
numberFormat,
getNumberFormatter,
@@ -90,6 +91,7 @@ function resetGlobals() {
jest.restoreAllMocks();
jest.clearAllMocks();
extConfig.disableLogging = false;
+ setLoggingDisabled(false);
extConfig.colorTheme = "classic";
extConfig.numberDisplayFormat = "compactShort";
isShorts.mockReturnValue(false);
@@ -448,28 +450,21 @@ describe("utils", () => {
});
describe("initializeLogging", () => {
- it("disables console output when configured", () => {
- extConfig.disableLogging = true;
- const originalLog = console.log;
- const originalDebug = console.debug;
-
- initializeLogging();
-
- expect(console.log).not.toBe(originalLog);
- expect(console.debug).not.toBe(originalDebug);
- });
-
- it("restores console output when logging is enabled", () => {
- const stubLog = jest.fn();
- const stubDebug = jest.fn();
- console.log = stubLog;
- console.debug = stubDebug;
-
- extConfig.disableLogging = false;
- initializeLogging();
-
- expect(console.log).not.toBe(stubLog);
- expect(console.debug).not.toBe(stubDebug);
+ it("applies the preference to extension logging without replacing console methods", () => {
+ const log = jest.spyOn(console, "log").mockImplementation(() => {});
+ const debug = jest.spyOn(console, "debug").mockImplementation(() => {});
+ for (const disabled of [true, false, true]) {
+ log.mockClear();
+ debug.mockClear();
+ extConfig.disableLogging = disabled;
+ initializeLogging();
+ logger.log("extension log");
+ logger.debug("extension debug");
+ expect(console.log).toBe(log);
+ expect(console.debug).toBe(debug);
+ expect(log).toHaveBeenCalledTimes(disabled ? 0 : 1);
+ expect(debug).toHaveBeenCalledTimes(disabled ? 0 : 1);
+ }
});
});
diff --git a/Extensions/combined/src/vote-data-request.js b/Extensions/combined/src/vote-data-request.js
index 33be055..7e1f79e 100644
--- a/Extensions/combined/src/vote-data-request.js
+++ b/Extensions/combined/src/vote-data-request.js
@@ -1,5 +1,6 @@
import { getApiEndpoint } from "./config";
import { fetchWithCoreConsent } from "./core-data-consent";
+import { isPrivateBrowsingContext } from "./private-browsing";
const WITHOUT_LIKE_COUNT = "without-like-count";
const EXTENSION_ID_PATTERN = /^[a-p]{32}$/;
@@ -100,6 +101,7 @@ function requestVoteData(videoId, { fetchImpl = globalThis.fetch, likeCount = nu
Accept: createVoteDataAcceptHeader(),
},
signal,
+ ...(isPrivateBrowsingContext() ? { credentials: "omit", cache: "no-store" } : {}),
},
fetchImpl,
);
diff --git a/Extensions/combined/src/vote-data-request.spec.js b/Extensions/combined/src/vote-data-request.spec.js
index 5ecb23c..8aa1138 100644
--- a/Extensions/combined/src/vote-data-request.spec.js
+++ b/Extensions/combined/src/vote-data-request.spec.js
@@ -2,6 +2,12 @@ jest.mock("./config", () => ({
getApiEndpoint: (pathname) => `https://api.test${pathname}`,
}));
+jest.mock("./private-browsing", () => ({
+ ...jest.requireActual("./private-browsing"),
+ isPrivateBrowsingContext: jest.fn(),
+}));
+import { isPrivateBrowsingContext } from "./private-browsing";
+
import {
cancelObsoleteVoteDataRequests,
clearVoteDataRequestCache,
@@ -24,10 +30,23 @@ function response(payload = { dislikes: 12, likes: 34 }) {
}
beforeEach(() => {
+ isPrivateBrowsingContext.mockReturnValue(false);
clearVoteDataRequestCache();
global.fetch = jest.fn().mockResolvedValue(response());
});
+test("private count requests omit credentials and HTTP cache use without account headers", async () => {
+ isPrivateBrowsingContext.mockReturnValue(true);
+ await requestVoteData(VIDEO_A);
+ expect(fetch).toHaveBeenCalledWith(`https://api.test/votes?videoId=${VIDEO_A}`, {
+ method: "GET",
+ headers: { Accept: "application/json" },
+ credentials: "omit",
+ cache: "no-store",
+ signal: expect.any(AbortSignal),
+ });
+});
+
afterAll(() => {
delete global.fetch;
});
diff --git a/Extensions/e2e/extension/extension-owned-page-locale.e2e.js b/Extensions/e2e/extension/extension-owned-page-locale.e2e.js
new file mode 100644
index 0000000..039e9b7
--- /dev/null
+++ b/Extensions/e2e/extension/extension-owned-page-locale.e2e.js
@@ -0,0 +1,165 @@
+const path = require("node:path");
+const { chromium, test, expect } = require("@playwright/test");
+const { VIDEO_A } = require("../../UserScript/e2e/harness");
+const { HermeticExtensionArtifactAdapter, startHermeticApiServer } = require("../hermetic-artifact-smoke");
+
+const EXTENSION_ARTIFACT = path.resolve(
+ process.env.RYD_EXTENSION_ARTIFACT || path.join(__dirname, "../../combined/dist/chrome"),
+);
+
+async function assertSettingsLayout(page, direction, testInfo) {
+ await page.locator("#advancedToggle").click();
+ await expect(page.locator("#advancedSettings")).toHaveCSS("opacity", "1");
+ const layout = await page.evaluate(() => {
+ const rect = (element) => {
+ const { left, right, top, bottom } = element.getBoundingClientRect();
+ return { left, right, top, bottom };
+ };
+ const panel = document.getElementById("advancedSettings");
+ const panelLeft = panel.getBoundingClientRect().left + panel.clientLeft;
+ return {
+ clipLeft: panelLeft,
+ clipRight: panelLeft + panel.clientWidth,
+ toggle: rect(document.getElementById("advancedToggle")),
+ switches: [...panel.querySelectorAll(".switchLabel")].map((label) => ({
+ label: rect(label),
+ slider: rect(label.closest(".switch").querySelector(".slider")),
+ })),
+ };
+ });
+ expect(layout.switches.length).toBeGreaterThan(0);
+ for (const { label, slider } of layout.switches) {
+ if (direction === "rtl") expect(label.right).toBeLessThanOrEqual(slider.left + 0.5);
+ else expect(label.left).toBeGreaterThanOrEqual(slider.right - 0.5);
+ expect(label.left).toBeGreaterThanOrEqual(layout.clipLeft - 0.5);
+ expect(label.right).toBeLessThanOrEqual(layout.clipRight + 0.5);
+ const toggleOverlapsSlider =
+ layout.toggle.left < slider.right &&
+ layout.toggle.right > slider.left &&
+ layout.toggle.top < slider.bottom &&
+ layout.toggle.bottom > slider.top;
+ expect(toggleOverlapsSlider).toBe(false);
+ }
+ await page.screenshot({ path: testInfo.outputPath("settings-375.png"), fullPage: true });
+
+ const hoverTips = page.locator(".switch[data-hover], .label-with-hover-tip[data-hover]");
+ expect(await hoverTips.count()).toBeGreaterThan(0);
+ for (const host of await hoverTips.all()) {
+ await page.mouse.move(0, 0);
+ await expect
+ .poll(() =>
+ hoverTips.evaluateAll((elements) =>
+ elements.every((element) => getComputedStyle(element, "::before").visibility === "hidden"),
+ ),
+ )
+ .toBe(true);
+ await host.hover();
+ await expect
+ .poll(() => host.evaluate((element) => getComputedStyle(element, "::before").visibility))
+ .toBe("visible");
+ const bounds = await host.evaluate((element) => {
+ const host = element.getBoundingClientRect();
+ const tip = getComputedStyle(element, "::before");
+ const width =
+ parseFloat(tip.width) +
+ (tip.boxSizing === "border-box"
+ ? 0
+ : parseFloat(tip.paddingLeft) +
+ parseFloat(tip.paddingRight) +
+ parseFloat(tip.borderLeftWidth) +
+ parseFloat(tip.borderRightWidth));
+ const left = tip.left === "auto" ? host.right - parseFloat(tip.right) - width : host.left + parseFloat(tip.left);
+ return { left, right: left + width };
+ });
+ expect(bounds.left).toBeGreaterThanOrEqual(layout.clipLeft - 0.5);
+ expect(bounds.right).toBeLessThanOrEqual(layout.clipRight + 0.5);
+ }
+
+ await page.mouse.move(0, 0);
+ await expect
+ .poll(() =>
+ hoverTips.evaluateAll((elements) =>
+ elements.every((element) => getComputedStyle(element, "::before").visibility === "hidden"),
+ ),
+ )
+ .toBe(true);
+ await page.locator("#hide_clutter_buttons_help").hover();
+ const help = page.locator("#hide_clutter_buttons_tooltip");
+ await expect(help).toBeVisible();
+ const helpBounds = await help.boundingBox();
+ expect(helpBounds.x).toBeGreaterThanOrEqual(layout.clipLeft - 0.5);
+ expect(helpBounds.x + helpBounds.width).toBeLessThanOrEqual(layout.clipRight + 0.5);
+ await page.screenshot({ path: testInfo.outputPath("settings-help-375.png"), fullPage: true });
+}
+
+for (const { locale, language, direction } of [
+ { locale: "fa-IR", language: "fa", direction: "rtl" },
+ { locale: "en-US", language: "en", direction: "ltr" },
+]) {
+ test(`built popup and release page follow ${direction} locale metadata for ${locale}`, async ({}, testInfo) => {
+ const apiServer = await startHermeticApiServer();
+ const browserType = {
+ launchPersistentContext: (profile, options) =>
+ chromium.launchPersistentContext(profile, { ...options, args: [...options.args, `--lang=${language}`] }),
+ };
+ const adapter = new HermeticExtensionArtifactAdapter({
+ apiServer,
+ artifactDirectory: EXTENSION_ARTIFACT,
+ browserType,
+ contextOptions: { locale },
+ });
+ try {
+ await adapter.start();
+ await adapter.context.route("https://fonts.googleapis.com/**", (route) =>
+ route.fulfill({ body: "", contentType: "text/css", status: 200 }),
+ );
+ await adapter.context.route("https://fonts.gstatic.com/**", (route) =>
+ route.fulfill({ body: "", contentType: "font/woff2", status: 200 }),
+ );
+ await adapter.context.route("https://raw.githubusercontent.com/**", (route) =>
+ route.fulfill({ body: JSON.stringify({ version: "4.0.5" }), contentType: "application/json", status: 200 }),
+ );
+ expect(await adapter.worker.evaluate(() => chrome.i18n.getMessage("@@bidi_dir"))).toBe(direction);
+ await adapter.openWatch(VIDEO_A);
+ await adapter.waitForWatchResult(VIDEO_A);
+ const hostDirection = await adapter.page.evaluate(() => ({
+ attribute: document.documentElement.dir,
+ computed: getComputedStyle(document.documentElement).direction,
+ }));
+ const page = await adapter.context.newPage();
+ const pageErrors = [];
+ page.on("pageerror", (error) => pageErrors.push(error.message));
+ page.on("console", (message) => {
+ if (message.type() === "error") pageErrors.push(message.text());
+ });
+ for (const [name, url, widths] of [
+ ["popup", "popup.html", [375]],
+ ["release", "changelog/4/changelog_4.1.html", [375, 1280]],
+ ]) {
+ for (const width of widths) {
+ await page.setViewportSize({ width, height: 1000 });
+ await page.goto(`chrome-extension://${adapter.extensionId}/${url}`, { waitUntil: "load" });
+ await expect(page.locator("html")).toHaveAttribute("dir", direction);
+ await expect(page.locator("html")).toHaveCSS("direction", direction);
+ expect(await page.evaluate(() => document.documentElement.innerHTML.includes("__MSG_"))).toBe(false);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
+ await page.screenshot({ path: testInfo.outputPath(`${name}-${width}.png`), fullPage: true });
+ if (name === "popup") await assertSettingsLayout(page, direction, testInfo);
+ }
+ }
+ expect(
+ await adapter.page.evaluate(() => ({
+ attribute: document.documentElement.dir,
+ computed: getComputedStyle(document.documentElement).direction,
+ })),
+ ).toEqual(hostDirection);
+ await adapter.assertNoPageSignals(testInfo.title);
+ expect(pageErrors).toEqual([]);
+ expect(adapter.backend.blockedRequests).toEqual([]);
+ expect(apiServer.unexpectedRequests).toEqual([]);
+ } finally {
+ await adapter.close().catch(() => {});
+ await apiServer.close().catch(() => {});
+ }
+ });
+}
diff --git a/Extensions/e2e/extension/extension-premium-analytics-theme.e2e.js b/Extensions/e2e/extension/extension-premium-analytics-theme.e2e.js
new file mode 100644
index 0000000..9a367d8
--- /dev/null
+++ b/Extensions/e2e/extension/extension-premium-analytics-theme.e2e.js
@@ -0,0 +1,181 @@
+const { test, expect } = require("@playwright/test");
+const { VIDEO_A } = require("../../UserScript/e2e/harness");
+const { HermeticExtensionArtifactAdapter, startHermeticApiServer } = require("../hermetic-artifact-smoke");
+
+async function applyTheme(page, dark, nativeTokens) {
+ await page.evaluate(
+ ({ dark, nativeTokens }) => {
+ const root = document.documentElement;
+ root.toggleAttribute("dark", dark);
+ for (const [name, value] of Object.entries({
+ "--yt-spec-base-background": dark ? "#151923" : "#fff8f0",
+ "--yt-spec-text-primary": dark ? "#e4eaff" : "#302010",
+ "--yt-spec-text-secondary": dark ? "#abbbd2" : "#705030",
+ })) {
+ root.style.setProperty(name, nativeTokens ? value : "initial");
+ }
+ document.body.style.backgroundColor = dark ? "#0f0f0f" : "#fff";
+ },
+ { dark, nativeTokens },
+ );
+}
+
+for (const nativeTokens of [false, true]) {
+ test(`premium analytics follows the host theme with native color tokens ${nativeTokens}`, async () => {
+ const apiServer = await startHermeticApiServer();
+ const adapter = new HermeticExtensionArtifactAdapter({
+ apiServer,
+ contextOptions: { colorScheme: "dark" },
+ });
+ let analyticsRequests = 0;
+ const analyticsQueries = [];
+ try {
+ await adapter.start();
+ await adapter.context.addInitScript(() => {
+ if (location.hostname !== "www.youtube.com") return;
+ document.addEventListener("DOMContentLoaded", () => {
+ const secondary = document.createElement("aside");
+ secondary.id = "secondary";
+ secondary.style.cssText = "width: 520px; margin: 24px;";
+ const inner = document.createElement("div");
+ inner.id = "secondary-inner";
+ secondary.append(inner);
+ document.body.append(secondary);
+ });
+ });
+ await adapter.context.route(
+ (url) => url.pathname.startsWith("/api/patreon/analytics/video/"),
+ async (route) => {
+ analyticsRequests++;
+ const query = new URL(route.request().url()).searchParams;
+ analyticsQueries.push(Object.fromEntries(query));
+ const selectedStart = query.get("selectedRangeStartUtc") || "2026-01-01T00:00:00Z";
+ const selectedEnd =
+ query.get("selectedRangeEndUtc") ||
+ new Date(Date.parse(selectedStart) + Number(query.get("rangeDays") || 30) * 86400000).toISOString();
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ headers: { "access-control-allow-origin": "*" },
+ body: JSON.stringify({
+ timeSeries: {
+ bucket: "day",
+ totalRangeStartUtc: "2026-01-01T00:00:00Z",
+ totalRangeEndUtc: "2026-04-01T00:00:00Z",
+ selectedRangeStartUtc: selectedStart,
+ selectedRangeEndUtc: selectedEnd,
+ points: [
+ { timestampUtc: selectedStart, likes: 10, dislikes: 2 },
+ { timestampUtc: selectedEnd, likes: 20, dislikes: 3 },
+ ],
+ },
+ geo: { countries: [], subdivisions: [], topLikes: [], topDislikes: [] },
+ summary: { totalLikes: 30, totalDislikes: 5, uniqueIps: 20, countriesRepresented: 0 },
+ }),
+ });
+ },
+ );
+ await adapter.context.route(
+ (url) => url.pathname === "/api/discussion/me",
+ (route) => route.fulfill({ status: 401, contentType: "application/json", body: '{"error":"invalid_session"}' }),
+ );
+ await adapter.worker.evaluate(async () => {
+ await chrome.storage.sync.set({
+ patreonAuthenticated: true,
+ patreonSessionToken: "synthetic-theme-session",
+ patreonUser: { fullName: "Synthetic member", hasActiveMembership: true, membershipTier: "premium" },
+ });
+ });
+ await adapter.openWatch(VIDEO_A);
+ const panel = adapter.page.locator(".ryd-premium-analytics");
+ await expect(panel).toBeVisible();
+ await expect(panel).toHaveAttribute("aria-busy", "false");
+ await expect(panel.locator("canvas").first()).toBeVisible();
+ const controlLayout = () =>
+ panel.evaluate((element) => ({
+ panel: element.getBoundingClientRect().toJSON(),
+ buttons: [...element.querySelectorAll(".ryd-range, .ryd-range-anchor")].map((button) =>
+ button.getBoundingClientRect().toJSON(),
+ ),
+ }));
+ const normalLayout = await controlLayout();
+ await adapter.page.locator("#secondary").evaluate((element) => (element.style.width = "332px"));
+ const narrowLayout = await controlLayout();
+ expect(narrowLayout.panel.width).toBe(332);
+ expect(narrowLayout.buttons).toHaveLength(6);
+ for (const button of narrowLayout.buttons) {
+ expect(button.left).toBeGreaterThanOrEqual(narrowLayout.panel.left);
+ expect(button.right).toBeLessThanOrEqual(narrowLayout.panel.right);
+ }
+ await adapter.page.locator("#secondary").evaluate((element) => (element.style.width = "520px"));
+ const restoredLayout = await controlLayout();
+ restoredLayout.buttons.forEach((button, index) => {
+ expect(button.left).toBeCloseTo(normalLayout.buttons[index].left, 1);
+ expect(button.top).toBeCloseTo(normalLayout.buttons[index].top, 1);
+ expect(button.width).toBeCloseTo(normalLayout.buttons[index].width, 1);
+ expect(button.height).toBeCloseTo(normalLayout.buttons[index].height, 1);
+ });
+ for (const dark of [false, true, false]) {
+ const requestsBefore = analyticsRequests;
+ await applyTheme(adapter.page, dark, nativeTokens);
+ const background = nativeTokens
+ ? dark
+ ? "rgb(21, 25, 35)"
+ : "rgb(255, 248, 240)"
+ : dark
+ ? "rgb(32, 32, 32)"
+ : "rgb(255, 255, 255)";
+ const foreground = nativeTokens
+ ? dark
+ ? "rgb(228, 234, 255)"
+ : "rgb(48, 32, 16)"
+ : dark
+ ? "rgb(255, 255, 255)"
+ : "rgb(15, 15, 15)";
+ await expect(panel).toHaveCSS("background-color", background);
+ await expect(panel).toHaveCSS("color", foreground);
+ await expect(panel.locator(".ryd-analytics__totals-value").first()).toHaveCSS("color", foreground);
+ await expect(panel.locator(".ryd-range").first()).toHaveCSS(
+ "color",
+ dark ? (nativeTokens ? "rgb(171, 187, 210)" : "rgb(187, 187, 187)") : "rgb(31, 41, 55)",
+ );
+ await adapter.page.waitForTimeout(500);
+ expect(analyticsRequests).toBe(requestsBefore);
+ for (const chart of ["activity", "lists", "map"]) {
+ const expand = panel.locator(`.ryd-analytics__section-expand[data-chart="${chart}"]`);
+ await expand.click();
+ await expect(panel.locator(`.ryd-analytics__section[data-chart="${chart}"]`)).toHaveCSS(
+ "background-color",
+ background,
+ );
+ await expand.click();
+ }
+ }
+ await adapter.page.locator('.ryd-range[data-range="7"]').click();
+ await expect(adapter.page.locator('.ryd-range[data-range="7"]')).toHaveClass(/is-active/);
+ await expect.poll(() => analyticsRequests).toBeGreaterThan(1);
+ await adapter.page.locator('.ryd-range[data-range="30"]').click();
+ await expect(panel).toHaveAttribute("aria-busy", "false");
+ const chart = await panel.locator("#ryd-analytics-activity").boundingBox();
+ const plotWidth = chart.width - 60;
+ const sliderX = chart.x + 40 + plotWidth / 6;
+ const sliderY = chart.y + chart.height - 29;
+ await adapter.page.mouse.move(sliderX, sliderY);
+ await adapter.page.mouse.down();
+ await adapter.page.mouse.move(sliderX + plotWidth / 6, sliderY, { steps: 10 });
+ await adapter.page.mouse.up();
+ await expect(panel.locator(".ryd-analytics__window-hint")).toHaveText("Custom selection");
+ await expect.poll(() => analyticsQueries.at(-1)).toHaveProperty("selectedRangeStartUtc");
+ await adapter.page.locator('.ryd-range[data-range="30"]').click();
+ await expect(panel.locator('.ryd-range[data-range="30"]')).toHaveClass(/is-active/);
+ await expect(panel.locator(".ryd-analytics__window-hint")).toHaveText("First 30 days");
+ await expect.poll(() => analyticsQueries.at(-1)).toMatchObject({ rangeDays: "30", rangeAnchor: "first" });
+ expect(analyticsQueries.at(-1)).not.toHaveProperty("selectedRangeStartUtc");
+ expect(adapter.backend.blockedRequests).toEqual([]);
+ expect(apiServer.unexpectedRequests).toEqual([]);
+ } finally {
+ await adapter.close();
+ await apiServer.close();
+ }
+ });
+}
diff --git a/Extensions/e2e/hermetic-artifact-smoke.js b/Extensions/e2e/hermetic-artifact-smoke.js
index 168cf39..7349777 100644
--- a/Extensions/e2e/hermetic-artifact-smoke.js
+++ b/Extensions/e2e/hermetic-artifact-smoke.js
@@ -466,7 +466,7 @@ function prepareHermeticExtensionArtifact(sourceDirectory, apiOrigin) {
"ryd.background.js still contains the production API origin after transformation.",
);
const changelogListener =
- /api\.runtime\.onInstalled\.addListener\(\(details\) => \{\r?\n maybeShowChangelog\(details\);\r?\n\}\);/g;
+ /api\.runtime\.onInstalled\.addListener\(\(details\) => \{\r?\n (?:maybeShowChangelog\(details\);|void handleInstallation\(details\);)\r?\n\}\);/g;
if ((transformedBackground.match(changelogListener) ?? []).length !== 1) {
removeOwnedTemporaryDirectory(temporaryRoot, "ryd-mv3-e2e-");
throw new Error("ryd.background.js must have exactly one recognized first-install changelog listener to suppress.");
diff --git a/Extensions/e2e/hermetic-artifact-smoke.spec.js b/Extensions/e2e/hermetic-artifact-smoke.spec.js
index 8217def..cfa578f 100644
--- a/Extensions/e2e/hermetic-artifact-smoke.spec.js
+++ b/Extensions/e2e/hermetic-artifact-smoke.spec.js
@@ -49,7 +49,7 @@ test("the extension adapter honors the selected artifact even when a scenario us
}
});
-function createExtensionFixture(lineEnding = "\n") {
+function createExtensionFixture(lineEnding = "\n", installedHandler = "maybeShowChangelog(details);") {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "ryd-mv3-source-fixture-"));
temporaryDirectories.push(directory);
fs.writeFileSync(
@@ -66,7 +66,7 @@ function createExtensionFixture(lineEnding = "\n") {
);
fs.writeFileSync(
path.join(directory, "ryd.background.js"),
- `fetch("${PRODUCTION_API_ORIGIN}/register")\napi.runtime.onInstalled.addListener((details) => {\n maybeShowChangelog(details);\n});`.replaceAll(
+ `fetch("${PRODUCTION_API_ORIGIN}/register")\napi.runtime.onInstalled.addListener((details) => {\n ${installedHandler}\n});`.replaceAll(
"\n",
lineEnding,
),
@@ -381,10 +381,15 @@ test("the routed fake backend records a valid preflight and blocks an unknown on
]);
});
-test.each(["\n", "\r\n"])(
- "redirects MV3 background traffic with %j line endings and preserves routed traffic",
- (lineEnding) => {
- const sourceDirectory = createExtensionFixture(lineEnding);
+test.each([
+ ["\n", "maybeShowChangelog(details);"],
+ ["\r\n", "maybeShowChangelog(details);"],
+ ["\n", "void handleInstallation(details);"],
+ ["\r\n", "void handleInstallation(details);"],
+])(
+ "redirects MV3 background traffic with %j line endings and %s while preserving routed traffic",
+ (lineEnding, installedHandler) => {
+ const sourceDirectory = createExtensionFixture(lineEnding, installedHandler);
const prepared = prepareHermeticExtensionArtifact(sourceDirectory, "http://127.0.0.1:43127");
temporaryDirectories.push(prepared.temporaryRoot);
diff --git a/README_AMO_SOURCE.md b/README_AMO_SOURCE.md
index ce148d1..ee748c3 100644
--- a/README_AMO_SOURCE.md
+++ b/README_AMO_SOURCE.md
@@ -4,7 +4,7 @@ This source package is provided for Mozilla Add-ons review. The extension is bui
## Build environment
-Build the 4.0.6 release with this exact toolchain:
+Build the 4.1.0 release with this exact toolchain:
- Windows 11 (PowerShell); the build script also supports Linux and macOS
- Node.js 22.17.0
@@ -49,7 +49,7 @@ The script performs all required technical build steps:
1. Confirms Node.js 22.17.0 and npm 10.8.2 are in use.
2. Installs the locked dependencies with `npm ci`.
3. Builds the extension with the release-specific `npm run build-for-amo` hook.
-4. Verifies the generated Firefox manifest is version 4.0.6, retains the published AMO add-on ID, and declares the expected desktop data-collection permissions and minimum Firefox version.
+4. Verifies the generated Firefox manifest is version 4.1.0, retains the published AMO add-on ID, and declares the expected desktop data-collection permissions and minimum Firefox version.
5. Verifies `menu-fixer.js` and the other required Firefox files were generated.
6. Verifies `ryd.content-script.js` is below 5 MB.
7. Verifies every generated JavaScript file has no source-map reference.