diff --git a/injected/integration-test/autofill-password-import.spec.js b/injected/integration-test/autofill-password-import.spec.js
index 7a0e7d9c3..488a60811 100644
--- a/injected/integration-test/autofill-password-import.spec.js
+++ b/injected/integration-test/autofill-password-import.spec.js
@@ -1,23 +1,26 @@
-import { test } from '@playwright/test';
+import { test, expect } from '@playwright/test';
import { readFileSync } from 'fs';
import { mockAndroidMessaging, wrapWebkitScripts } from '@duckduckgo/messaging/lib/test-utils.mjs';
import { perPlatform } from './type-helpers.mjs';
+import { OVERLAY_ID } from '../src/features/autofill-password-import';
test('Password import feature', async ({ page }, testInfo) => {
const passwordImportFeature = AutofillPasswordImportSpec.create(page, testInfo);
await passwordImportFeature.enabled();
await passwordImportFeature.navigate();
- const didAnimatePasswordOptions = passwordImportFeature.waitForAnimation('a[aria-label="Password options"]');
await passwordImportFeature.clickOnElement('Home page');
- await didAnimatePasswordOptions;
+ await passwordImportFeature.waitForAnimation();
- const didAnimateSignin = passwordImportFeature.waitForAnimation('a[aria-label="Sign in"]');
await passwordImportFeature.clickOnElement('Signin page');
- await didAnimateSignin;
+ await passwordImportFeature.waitForAnimation();
- const didAnimateExport = passwordImportFeature.waitForAnimation('button[aria-label="Export"]');
await passwordImportFeature.clickOnElement('Export page');
- await didAnimateExport;
+ await passwordImportFeature.waitForAnimation();
+
+ // Test unsupported path
+ await passwordImportFeature.clickOnElement('Unsupported page');
+ const overlay = page.locator(`#${OVERLAY_ID}`);
+ await expect(overlay).not.toBeVisible();
});
export class AutofillPasswordImportSpec {
@@ -91,17 +94,10 @@ export class AutofillPasswordImportSpec {
/**
* Helper to assert that an element is animating
- * @param {string} selector
*/
- async waitForAnimation(selector) {
- const locator = this.page.locator(selector);
- return await locator.evaluate((el) => {
- if (el != null) {
- return el.getAnimations().some((animation) => animation.playState === 'running');
- } else {
- return false;
- }
- }, selector);
+ async waitForAnimation() {
+ const locator = this.page.locator(`#${OVERLAY_ID}`);
+ await expect(locator).toBeVisible();
}
/**
diff --git a/injected/integration-test/test-pages/autofill-password-import/index.html b/injected/integration-test/test-pages/autofill-password-import/index.html
index 10187c088..fc0799f81 100644
--- a/injected/integration-test/test-pages/autofill-password-import/index.html
+++ b/injected/integration-test/test-pages/autofill-password-import/index.html
@@ -32,17 +32,21 @@
Sign in
-
+
+
+
+
diff --git a/injected/src/features/autofill-password-import.js b/injected/src/features/autofill-password-import.js
index 0be34fa7f..db6f14207 100644
--- a/injected/src/features/autofill-password-import.js
+++ b/injected/src/features/autofill-password-import.js
@@ -1,8 +1,29 @@
import ContentFeature from '../content-feature';
import { DDGProxy, DDGReflect, withExponentialBackoff } from '../utils';
-const ANIMATION_DURATION_MS = 1000;
-const ANIMATION_ITERATIONS = Infinity;
+export const ANIMATION_DURATION_MS = 1000;
+export const ANIMATION_ITERATIONS = Infinity;
+export const BACKGROUND_COLOR_START = 'rgba(85, 127, 243, 0.10)';
+export const BACKGROUND_COLOR_END = 'rgba(85, 127, 243, 0.25)';
+export const OVERLAY_ID = 'ddg-password-import-overlay';
+export const DELAY_BEFORE_ANIMATION = 300;
+
+/**
+ * @typedef ButtonAnimationStyle
+ * @property {Record} transform
+ * @property {string} zIndex
+ * @property {string} borderRadius
+ * @property {number} offsetLeftEm
+ * @property {number} offsetTopEm
+ */
+
+/**
+ * @typedef ElementConfig
+ * @property {HTMLElement|Element|SVGElement} element
+ * @property {ButtonAnimationStyle} animationStyle
+ * @property {boolean} shouldTap
+ * @property {boolean} shouldWatchForRemoval
+ */
/**
* This feature is responsible for animating some buttons passwords.google.com,
@@ -13,70 +34,132 @@ const ANIMATION_ITERATIONS = Infinity;
*/
export default class AutofillPasswordImport extends ContentFeature {
#exportButtonSettings;
+
#settingsButtonSettings;
+
#signInButtonSettings;
+ /** @type {HTMLElement|Element|SVGElement|null} */
+ #elementToCenterOn;
+
+ /** @type {HTMLElement|null} */
+ #currentOverlay;
+
+ /** @type {ElementConfig|null} */
+ #currentElementConfig;
+
+ #domLoaded;
+
/**
- * @returns {any}
+ * @returns {ButtonAnimationStyle}
*/
- get settingsButtonStyle() {
+ get settingsButtonAnimationStyle() {
return {
- scale: 1,
- backgroundColor: 'rgba(0, 39, 142, 0.5)',
+ transform: {
+ start: 'scale(0.90)',
+ mid: 'scale(0.96)',
+ },
+ zIndex: '984',
+ borderRadius: '100%',
+ offsetLeftEm: 0.01,
+ offsetTopEm: 0,
};
}
/**
- * @returns {any}
+ * @returns {ButtonAnimationStyle}
*/
- get exportButtonStyle() {
+ get exportButtonAnimationStyle() {
return {
- scale: 1.01,
- backgroundColor: 'rgba(0, 39, 142, 0.5)',
+ transform: {
+ start: 'scale(1)',
+ mid: 'scale(1.01)',
+ },
+ zIndex: '984',
+ borderRadius: '100%',
+ offsetLeftEm: 0,
+ offsetTopEm: 0,
};
}
/**
- * @returns {any}
+ * @returns {ButtonAnimationStyle}
*/
- get signInButtonStyle() {
+ get signInButtonAnimationStyle() {
return {
- scale: 1.5,
- backgroundColor: 'rgba(0, 39, 142, 0.5)',
+ transform: {
+ start: 'scale(1)',
+ mid: 'scale(1.3, 1.5)',
+ },
+ zIndex: '999',
+ borderRadius: '2px',
+ offsetLeftEm: 0,
+ offsetTopEm: -0.05,
};
}
+ /**
+ * @param {HTMLElement|null} overlay
+ */
+ set currentOverlay(overlay) {
+ this.#currentOverlay = overlay;
+ }
+
+ /**
+ * @returns {HTMLElement|null}
+ */
+ get currentOverlay() {
+ return this.#currentOverlay ?? null;
+ }
+
+ /**
+ * @returns {ElementConfig|null}
+ */
+ get currentElementConfig() {
+ return this.#currentElementConfig;
+ }
+
+ /**
+ * @returns {Promise}
+ */
+ get domLoaded() {
+ return this.#domLoaded;
+ }
+
/**
* Takes a path and returns the element and style to animate.
* @param {string} path
- * @returns {Promise<{element: HTMLElement|Element, style: any, shouldTap: boolean}|null>}
+ * @returns {Promise}
*/
async getElementAndStyleFromPath(path) {
if (path === '/') {
const element = await this.findSettingsElement();
return element != null
? {
- style: this.settingsButtonStyle,
+ animationStyle: this.settingsButtonAnimationStyle,
element,
shouldTap: this.#settingsButtonSettings?.shouldAutotap ?? false,
+ shouldWatchForRemoval: false,
}
: null;
} else if (path === '/options') {
const element = await this.findExportElement();
return element != null
? {
- style: this.exportButtonStyle,
+ animationStyle: this.exportButtonAnimationStyle,
element,
shouldTap: this.#exportButtonSettings?.shouldAutotap ?? false,
+ shouldWatchForRemoval: true,
}
: null;
} else if (path === '/intro') {
const element = await this.findSignInButton();
return element != null
? {
- style: this.signInButtonStyle,
+ animationStyle: this.signInButtonAnimationStyle,
element,
shouldTap: this.#signInButtonSettings?.shouldAutotap ?? false,
+ shouldWatchForRemoval: false,
}
: null;
} else {
@@ -84,31 +167,161 @@ export default class AutofillPasswordImport extends ContentFeature {
}
}
+ /**
+ * Removes the overlay if it exists.
+ */
+ removeOverlayIfNeeded() {
+ if (this.currentOverlay != null) {
+ this.currentOverlay.style.display = 'none';
+ this.currentOverlay.remove();
+ this.currentOverlay = null;
+ document.removeEventListener('scroll', this);
+ }
+ }
+
+ /**
+ * Updates the position of the overlay based on the element to center on.
+ */
+ updateOverlayPosition() {
+ if (this.currentOverlay != null && this.currentElementConfig?.animationStyle != null && this.elementToCenterOn != null) {
+ const animations = this.currentOverlay.getAnimations();
+ animations.forEach((animation) => animation.pause());
+ const { top, left, width, height } = this.elementToCenterOn.getBoundingClientRect();
+ this.currentOverlay.style.position = 'absolute';
+
+ const { animationStyle } = this.currentElementConfig;
+ const isRound = animationStyle.borderRadius === '100%';
+
+ const widthOffset = isRound ? width / 2 : 0;
+ const heightOffset = isRound ? height / 2 : 0;
+
+ this.currentOverlay.style.top = `calc(${top}px + ${window.scrollY}px - ${widthOffset}px - 1px - ${animationStyle.offsetTopEm}em)`;
+ this.currentOverlay.style.left = `calc(${left}px + ${window.scrollX}px - ${heightOffset}px - 1px - ${animationStyle.offsetLeftEm}em)`;
+
+ // Ensure overlay is non-interactive
+ this.currentOverlay.style.pointerEvents = 'none';
+ animations.forEach((animation) => animation.play());
+ }
+ }
+
+ /**
+ * Creates an overlay element to animate, by adding a div to the body
+ * and styling it based on the found element.
+ * @param {HTMLElement|Element} mainElement
+ * @param {any} style
+ */
+ createOverlayElement(mainElement, style) {
+ this.removeOverlayIfNeeded();
+
+ const overlay = document.createElement('div');
+ overlay.setAttribute('id', OVERLAY_ID);
+
+ if (this.elementToCenterOn != null) {
+ this.currentOverlay = overlay;
+ this.updateOverlayPosition();
+ const mainElementRect = mainElement.getBoundingClientRect();
+ overlay.style.width = `${mainElementRect.width}px`;
+ overlay.style.height = `${mainElementRect.height}px`;
+ overlay.style.zIndex = style.zIndex;
+
+ // Ensure overlay is non-interactive
+ overlay.style.pointerEvents = 'none';
+
+ // insert in document.body
+ document.body.appendChild(overlay);
+
+ document.addEventListener('scroll', this, { passive: true });
+ } else {
+ this.currentOverlay = null;
+ }
+ }
+
+ /**
+ * Observes the removal of an element from the DOM.
+ * @param {HTMLElement|Element} element
+ * @param {any} onRemoveCallback
+ */
+ observeElementRemoval(element, onRemoveCallback) {
+ // Set up the mutation observer
+ const observer = new MutationObserver((mutations) => {
+ mutations.forEach((mutation) => {
+ // Check if the element has been removed from its parent
+ if (mutation.type === 'childList' && !document.contains(element)) {
+ // Element has been removed
+ onRemoveCallback();
+ observer.disconnect(); // Stop observing
+ }
+ });
+ });
+
+ // Start observing the parent node for child list changes
+ observer.observe(document.body, { childList: true, subtree: true });
+ }
+
+ /**
+ *
+ * @param {HTMLElement|Element|SVGElement} element
+ * @param {ButtonAnimationStyle} style
+ */
+ setElementToCenterOn(element, style) {
+ const svgElement = element.parentNode?.querySelector('svg') ?? element.querySelector('svg');
+ this.#elementToCenterOn = style.borderRadius === '100%' && svgElement != null ? svgElement : element;
+ }
+
+ /**
+ * @returns {HTMLElement|Element|SVGElement|null}
+ */
+ get elementToCenterOn() {
+ return this.#elementToCenterOn;
+ }
+
/**
* Moves the element into view and animates it.
* @param {HTMLElement|Element} element
- * @param {any} style
+ * @param {ButtonAnimationStyle} style
*/
animateElement(element, style) {
- element.scrollIntoView({
- behavior: 'smooth',
- block: 'center',
- inline: 'center',
- }); // Scroll into view
- const keyframes = [
- { backgroundColor: 'rgba(0, 0, 255, 0)', offset: 0, borderRadius: '2px' }, // Start: transparent
- { backgroundColor: style.backgroundColor, offset: 0.5, borderRadius: '2px', transform: `scale(${style.scale})` }, // Midpoint: blue with 50% opacity
- { backgroundColor: 'rgba(0, 0, 255, 0)', borderRadius: '2px', offset: 1 }, // End: transparent
- ];
-
- // Define the animation options
- const options = {
- duration: ANIMATION_DURATION_MS,
- iterations: ANIMATION_ITERATIONS,
- };
+ this.createOverlayElement(element, style);
+ if (this.currentOverlay != null) {
+ this.currentOverlay.scrollIntoView({
+ behavior: 'smooth',
+ block: 'center',
+ inline: 'center',
+ }); // Scroll into view
+ const keyframes = [
+ {
+ backgroundColor: BACKGROUND_COLOR_START,
+ offset: 0,
+ borderRadius: style.borderRadius,
+ border: `1px solid ${BACKGROUND_COLOR_START}`,
+ transform: style.transform.start,
+ }, // Start: 10% blue
+ {
+ backgroundColor: BACKGROUND_COLOR_END,
+ offset: 0.5,
+ borderRadius: style.borderRadius,
+ border: `1px solid ${BACKGROUND_COLOR_END}`,
+ transform: style.transform.mid,
+ transformOrigin: 'center',
+ }, // Middle: 25% blue
+ {
+ backgroundColor: BACKGROUND_COLOR_START,
+ offset: 1,
+ borderRadius: style.borderRadius,
+ border: `1px solid ${BACKGROUND_COLOR_START}`,
+ transform: style.transform.start,
+ }, // End: 10% blue
+ ];
- // Apply the animation to the element
- element.animate(keyframes, options);
+ // Define the animation options
+ const options = {
+ duration: ANIMATION_DURATION_MS,
+ iterations: ANIMATION_ITERATIONS,
+ };
+
+ // Apply the animation to the element
+ this.currentOverlay.animate(keyframes, options);
+ }
}
autotapElement(element) {
@@ -153,20 +366,63 @@ export default class AutofillPasswordImport extends ContentFeature {
}
/**
- * Checks if the path is supported and animates/taps the element if it is.
+ * @param {Event} event
+ */
+ handleEvent(event) {
+ if (event.type === 'scroll') {
+ requestAnimationFrame(() => this.updateOverlayPosition());
+ }
+ }
+
+ /**
+ * @param {ElementConfig|null} config
+ */
+ setCurrentElementConfig(config) {
+ if (config != null) {
+ this.#currentElementConfig = config;
+ this.setElementToCenterOn(config.element, config.animationStyle);
+ }
+ }
+
+ /**
+ * Checks if the path is supported for animation.
* @param {string} path
+ * @returns {boolean}
*/
- async handleElementForPath(path) {
- const supportedPaths = [this.#exportButtonSettings?.path, this.#settingsButtonSettings?.path, this.#signInButtonSettings?.path];
- if (supportedPaths.indexOf(path)) {
+ isSupportedPath(path) {
+ return [this.#exportButtonSettings?.path, this.#settingsButtonSettings?.path, this.#signInButtonSettings?.path].includes(path);
+ }
+
+ async handlePath(path) {
+ this.removeOverlayIfNeeded();
+ if (this.isSupportedPath(path)) {
try {
- const { element, style, shouldTap } = (await this.getElementAndStyleFromPath(path)) ?? {};
- if (element != null) {
- // eslint-disable-next-line @typescript-eslint/no-unused-expressions
- shouldTap ? this.autotapElement(element) : this.animateElement(element, style);
- }
+ this.setCurrentElementConfig(await this.getElementAndStyleFromPath(path));
+ await this.animateOrTapElement();
} catch {
- console.error('password-import: handleElementForPath failed for path:', path);
+ console.error('password-import: failed for path:', path);
+ }
+ }
+ }
+
+ /**
+ * Based on the current element config, animates the element or taps it.
+ * If the element should be watched for removal, it sets up a mutation observer.
+ */
+ async animateOrTapElement() {
+ const { element, animationStyle, shouldTap, shouldWatchForRemoval } = this.currentElementConfig ?? {};
+ if (element != null && animationStyle != null) {
+ if (shouldTap) {
+ this.autotapElement(element);
+ } else {
+ await this.domLoaded;
+ this.animateElement(element, animationStyle);
+ }
+ if (shouldWatchForRemoval) {
+ // Sometimes navigation events are not triggered, then we need to watch for removal
+ this.observeElementRemoval(element, () => {
+ this.removeOverlayIfNeeded();
+ });
}
}
}
@@ -222,22 +478,38 @@ export default class AutofillPasswordImport extends ContentFeature {
init() {
this.setButtonSettings();
- const handleElementForPath = this.handleElementForPath.bind(this);
+ const handlePath = this.handlePath.bind(this);
const historyMethodProxy = new DDGProxy(this, History.prototype, 'pushState', {
async apply(target, thisArg, args) {
const path = args[1] === '' ? args[2].split('?')[0] : args[1];
- await handleElementForPath(path);
+ await handlePath(path);
return DDGReflect.apply(target, thisArg, args);
},
});
historyMethodProxy.overload();
// listen for popstate events in order to run on back/forward navigations
window.addEventListener('popstate', async () => {
- await handleElementForPath(window.location.pathname);
+ const path = window.location.pathname;
+ await handlePath(path);
});
- document.addEventListener('DOMContentLoaded', async () => {
- await handleElementForPath(window.location.pathname);
+ this.#domLoaded = new Promise((resolve) => {
+ if (document.readyState !== 'loading') {
+ // @ts-expect-error - caller doesn't expect a value here
+ resolve();
+ return;
+ }
+
+ document.addEventListener(
+ 'DOMContentLoaded',
+ async () => {
+ // @ts-expect-error - caller doesn't expect a value here
+ resolve();
+ const path = window.location.pathname;
+ await handlePath(path);
+ },
+ { once: true },
+ );
});
}
}
diff --git a/injected/src/features/broker-protection/actions/captcha.js b/injected/src/features/broker-protection/actions/captcha.js
index 48e285573..a065b2859 100644
--- a/injected/src/features/broker-protection/actions/captcha.js
+++ b/injected/src/features/broker-protection/actions/captcha.js
@@ -14,8 +14,9 @@ export function getCaptchaInfo(action, root = document) {
const captchaDiv = getElement(root, action.selector);
// if 'captchaDiv' was missing, cannot continue
- if (!captchaDiv)
+ if (!captchaDiv) {
return new ErrorResponse({ actionID: action.id, message: `could not find captchaDiv with selector ${action.selector}` });
+ }
// try 2 different captures
const captcha =
diff --git a/injected/src/features/duckplayer/components/ddg-video-overlay-mobile.js b/injected/src/features/duckplayer/components/ddg-video-overlay-mobile.js
index daaf51c61..5d0f69779 100644
--- a/injected/src/features/duckplayer/components/ddg-video-overlay-mobile.js
+++ b/injected/src/features/duckplayer/components/ddg-video-overlay-mobile.js
@@ -89,8 +89,9 @@ export class DDGVideoOverlayMobile extends HTMLElement {
const cancelElement = containerElement.querySelector('.ddg-vpo-cancel');
const watchInPlayer = containerElement.querySelector('.ddg-vpo-open');
- if (!infoButton || !cancelElement || !watchInPlayer || !switchElem || !(remember instanceof HTMLInputElement))
+ if (!infoButton || !cancelElement || !watchInPlayer || !switchElem || !(remember instanceof HTMLInputElement)) {
return console.warn('missing elements');
+ }
infoButton.addEventListener('click', () => {
this.dispatchEvent(new Event(DDGVideoOverlayMobile.OPEN_INFO));
diff --git a/injected/src/features/duckplayer/util.js b/injected/src/features/duckplayer/util.js
index df3f2a1a7..d39d44615 100644
--- a/injected/src/features/duckplayer/util.js
+++ b/injected/src/features/duckplayer/util.js
@@ -61,8 +61,9 @@ export function appendImageAsBackground(parent, targetSelector, imageUrl) {
*/
function append() {
const targetElement = parent.querySelector(targetSelector);
- if (!(targetElement instanceof HTMLElement))
+ if (!(targetElement instanceof HTMLElement)) {
return console.warn('could not find child with selector', targetSelector, 'from', parent);
+ }
parent.dataset.thumbLoaded = String(true);
parent.dataset.thumbSrc = imageUrl;
const img = new Image();
diff --git a/special-pages/pages/release-notes/app/components/ReleaseNotes.js b/special-pages/pages/release-notes/app/components/ReleaseNotes.js
index 96682aaef..1841335fd 100644
--- a/special-pages/pages/release-notes/app/components/ReleaseNotes.js
+++ b/special-pages/pages/release-notes/app/components/ReleaseNotes.js
@@ -92,11 +92,13 @@ function StatusTimestamp({ timestamp }) {
date.getDate() === yesterday.getDate() &&
date.getMonth() === yesterday.getMonth() &&
date.getFullYear() === yesterday.getFullYear()
- )
+ ) {
dateString = t('yesterdayAt', { time: timeString });
+ }
- if (date.getDate() === today.getDate() && date.getMonth() === today.getMonth() && date.getFullYear() === today.getFullYear())
+ if (date.getDate() === today.getDate() && date.getMonth() === today.getMonth() && date.getFullYear() === today.getFullYear()) {
dateString = t('todayAt', { time: timeString });
+ }
return (