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
- Password options + PO
- + +
+
+
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 (