diff --git a/special-pages/package.json b/special-pages/package.json index f49460067..c73fe38b5 100644 --- a/special-pages/package.json +++ b/special-pages/package.json @@ -6,7 +6,7 @@ "main": "index.js", "type": "module", "scripts": { - "prebuild": "node types.mjs", + "prebuild": "node types.mjs && node translations.mjs", "build": "node index.mjs", "build.dev": "npm run build -- --env development", "test-unit": "node --test unit-test/translations.mjs pages/duckplayer/unit-tests/embed-settings.mjs", @@ -20,7 +20,7 @@ "test.headed": "npm run test-int -- --headed", "test.ui": "npm run test-int -- --ui", "serve": "http-server -c-1 --port 3210 ../build/integration/pages", - "watch": "chokidar pages shared --initial -c 'npm run build.dev'" + "watch": "chokidar pages shared --initial -c 'npm run build.dev' --ignore 'pages/**/locales'" }, "license": "ISC", "devDependencies": { diff --git a/special-pages/pages/new-tab/app/favorites/components/Favorites.js b/special-pages/pages/new-tab/app/favorites/components/Favorites.js index e2e4169f0..5f267e933 100644 --- a/special-pages/pages/new-tab/app/favorites/components/Favorites.js +++ b/special-pages/pages/new-tab/app/favorites/components/Favorites.js @@ -6,7 +6,7 @@ import cn from 'classnames'; import styles from './Favorites.module.css'; import { Placeholder, PlusIconMemo, TileMemo } from './Tile.js'; import { ShowHideButton } from '../../components/ShowHideButton.jsx'; -import { useTypedTranslation } from '../../types.js'; +import { useTypedTranslationWith } from '../../types.js'; import { usePlatformName } from '../../settings.provider.js'; import { useDropzoneSafeArea } from '../../dropzone.js'; @@ -31,7 +31,7 @@ export const FavoritesMemo = memo(Favorites); */ export function Favorites({ gridRef, favorites, expansion, toggle, openContextMenu, openFavorite, add }) { const platformName = usePlatformName(); - const { t } = useTypedTranslation(); + const { t } = useTypedTranslationWith(/** @type {import('../strings.json')} */ ({})); const safeArea = useDropzoneSafeArea(); const ROW_CAPACITY = 6; diff --git a/special-pages/pages/new-tab/app/favorites/components/FavoritesCustomized.js b/special-pages/pages/new-tab/app/favorites/components/FavoritesCustomized.js index bdea5fc02..15fe22fb2 100644 --- a/special-pages/pages/new-tab/app/favorites/components/FavoritesCustomized.js +++ b/special-pages/pages/new-tab/app/favorites/components/FavoritesCustomized.js @@ -1,7 +1,7 @@ import { h } from 'preact'; import { useContext } from 'preact/hooks'; -import { useTelemetry, useTypedTranslation } from '../../types.js'; +import { useTelemetry, useTypedTranslationWith } from '../../types.js'; import { useVisibility } from '../../widget-list/widget-config.provider.js'; import { useCustomizer } from '../../customizer/components/Customizer.js'; @@ -38,7 +38,7 @@ export function FavoritesConsumer() { * Render the favorites widget, with integration into the page customizer */ export function FavoritesCustomized() { - const { t } = useTypedTranslation(); + const { t } = useTypedTranslationWith(/** @type {import("../strings.json")} */ ({})); const { id, visibility, toggle, index } = useVisibility(); // register with the visibility menu diff --git a/special-pages/pages/new-tab/app/favorites/strings.json b/special-pages/pages/new-tab/app/favorites/strings.json new file mode 100644 index 000000000..b2589c25c --- /dev/null +++ b/special-pages/pages/new-tab/app/favorites/strings.json @@ -0,0 +1,14 @@ +{ + "favorites_show_less": { + "title": "Show less", + "note": "Button label to display fewer items" + }, + "favorites_show_more": { + "title": "Show more ({count} remaining)", + "note": "Button text to show hidden items. {count} will be replaced with the number of remaining favorite items to show, including the parentheses. Example: 'Show more (18 remaining)'" + }, + "favorites_menu_title": { + "title": "Favorites", + "note": "Used as a label in a customization menu" + } +} diff --git a/special-pages/pages/new-tab/app/index.js b/special-pages/pages/new-tab/app/index.js index 83abc72dd..049a93425 100644 --- a/special-pages/pages/new-tab/app/index.js +++ b/special-pages/pages/new-tab/app/index.js @@ -7,7 +7,7 @@ import { SettingsProvider } from './settings.provider.js'; import { InitialSetupContext, MessagingContext, TelemetryContext } from './types'; import { TranslationProvider } from '../../../shared/components/TranslationsProvider.js'; import { WidgetConfigService } from './widget-list/widget-config.service.js'; -import enStrings from '../src/locales/en/newtab.json'; +import enStrings from '../src/locales/en/new-tab.json'; import { WidgetConfigProvider } from './widget-list/widget-config.provider.js'; import { Settings } from './settings.js'; import { Components } from './components/Components.jsx'; diff --git a/special-pages/pages/new-tab/src/js/mock-transport.js b/special-pages/pages/new-tab/app/mock-transport.js similarity index 91% rename from special-pages/pages/new-tab/src/js/mock-transport.js rename to special-pages/pages/new-tab/app/mock-transport.js index 68be9c0b6..c4dd80d9b 100644 --- a/special-pages/pages/new-tab/src/js/mock-transport.js +++ b/special-pages/pages/new-tab/app/mock-transport.js @@ -1,24 +1,24 @@ import { TestTransportConfig } from '@duckduckgo/messaging'; -import { stats } from '../../app/privacy-stats/mocks/stats.js'; -import { rmfDataExamples } from '../../app/remote-messaging-framework/mocks/rmf.data.js'; -import { favorites, gen } from '../../app/favorites/mocks/favorites.data.js'; -import { updateNotificationExamples } from '../../app/update-notification/mocks/update-notification.data.js'; -import { variants as nextSteps } from '../../app/next-steps/nextsteps.data.js'; +import { stats } from './privacy-stats/mocks/stats.js'; +import { rmfDataExamples } from './remote-messaging-framework/mocks/rmf.data.js'; +import { favorites, gen } from './favorites/mocks/favorites.data.js'; +import { updateNotificationExamples } from './update-notification/mocks/update-notification.data.js'; +import { variants as nextSteps } from './next-steps/nextsteps.data.js'; /** - * @typedef {import('../../../../types/new-tab').Favorite} Favorite - * @typedef {import('../../../../types/new-tab').FavoritesData} FavoritesData - * @typedef {import('../../../../types/new-tab').FavoritesConfig} FavoritesConfig - * @typedef {import('../../../../types/new-tab').StatsConfig} StatsConfig - * @typedef {import('../../../../types/new-tab').NextStepsConfig} NextStepsConfig - * @typedef {import('../../../../types/new-tab').NextStepsCards} NextStepsCards - * @typedef {import('../../../../types/new-tab').NextStepsData} NextStepsData - * @typedef {import('../../../../types/new-tab').UpdateNotificationData} UpdateNotificationData - * @typedef {import('../../../../types/new-tab.js').NewTabMessages['subscriptions']['subscriptionEvent']} SubscriptionNames + * @typedef {import('../../../types/new-tab').Favorite} Favorite + * @typedef {import('../../../types/new-tab').FavoritesData} FavoritesData + * @typedef {import('../../../types/new-tab').FavoritesConfig} FavoritesConfig + * @typedef {import('../../../types/new-tab').StatsConfig} StatsConfig + * @typedef {import('../../../types/new-tab').NextStepsConfig} NextStepsConfig + * @typedef {import('../../../types/new-tab').NextStepsCards} NextStepsCards + * @typedef {import('../../../types/new-tab').NextStepsData} NextStepsData + * @typedef {import('../../../types/new-tab').UpdateNotificationData} UpdateNotificationData + * @typedef {import('../../../types/new-tab').NewTabMessages['subscriptions']['subscriptionEvent']} SubscriptionNames */ -const VERSION_PREFIX = '__ntp_28__.'; +const VERSION_PREFIX = '__ntp_29__.'; const url = new URL(window.location.href); export function mockTransport() { @@ -75,7 +75,7 @@ export function mockTransport() { function clearRmf() { const listeners = rmfSubscriptions.get('rmf_onDataUpdate') || []; - /** @type {import('../../../../types/new-tab.js').RMFData} */ + /** @type {import('../../../types/new-tab.ts').RMFData} */ const message = { content: undefined }; for (const listener of listeners) { listener(message); @@ -85,7 +85,7 @@ export function mockTransport() { return new TestTransportConfig({ notify(_msg) { window.__playwright_01?.mocks?.outgoing?.push?.({ payload: structuredClone(_msg) }); - /** @type {import('../../../../types/new-tab.js').NewTabMessages['notifications']} */ + /** @type {import('../../../types/new-tab.ts').NewTabMessages['notifications']} */ const msg = /** @type {any} */ (_msg); switch (msg.method) { case 'widgets_setConfig': { @@ -154,7 +154,7 @@ export function mockTransport() { }, subscribe(_msg, cb) { window.__playwright_01?.mocks?.outgoing?.push?.({ payload: structuredClone(_msg) }); - /** @type {import('../../../../types/new-tab.js').NewTabMessages['subscriptions']['subscriptionEvent']} */ + /** @type {import('../../../types/new-tab.ts').NewTabMessages['subscriptions']['subscriptionEvent']} */ const sub = /** @type {any} */ (_msg.subscriptionName); switch (sub) { case 'widgets_onConfigUpdated': { @@ -303,7 +303,7 @@ export function mockTransport() { // eslint-ignore-next-line require-await request(_msg) { window.__playwright_01?.mocks?.outgoing?.push?.({ payload: structuredClone(_msg) }); - /** @type {import('../../../../types/new-tab.js').NewTabMessages['requests']} */ + /** @type {import('../../../types/new-tab.ts').NewTabMessages['requests']} */ const msg = /** @type {any} */ (_msg); switch (msg.method) { case 'stats_getData': { @@ -353,7 +353,7 @@ export function mockTransport() { return Promise.resolve(data); } case 'rmf_getData': { - /** @type {import('../../../../types/new-tab.js').RMFData} */ + /** @type {import('../../../types/new-tab.ts').RMFData} */ let message = { content: undefined }; const rmfParam = url.searchParams.get('rmf'); @@ -414,7 +414,7 @@ export function mockTransport() { updateNotification = updateNotificationExamples.populated; } - /** @type {import('../../../../types/new-tab.js').InitialSetupResponse} */ + /** @type {import('../../../types/new-tab.ts').InitialSetupResponse} */ const initial = { widgets: widgetsFromStorage, widgetConfigs: widgetConfigFromStorage, diff --git a/special-pages/pages/new-tab/app/next-steps/components/NextStepsCard.js b/special-pages/pages/new-tab/app/next-steps/components/NextStepsCard.js index e4eab3cf0..a769ada96 100644 --- a/special-pages/pages/new-tab/app/next-steps/components/NextStepsCard.js +++ b/special-pages/pages/new-tab/app/next-steps/components/NextStepsCard.js @@ -2,7 +2,7 @@ import { h } from 'preact'; import styles from './NextSteps.module.css'; import { DismissButton } from '../../components/DismissButton'; import { variants } from '../nextsteps.data'; -import { useTypedTranslation } from '../../types'; +import { useTypedTranslationWith } from '../../types'; /** * @param {object} props @@ -12,7 +12,7 @@ import { useTypedTranslation } from '../../types'; */ export function NextStepsCard({ type, dismiss, action }) { - const { t } = useTypedTranslation(); + const { t } = useTypedTranslationWith(/** @type {import("../strings.json")} */ ({})); const message = variants[type]?.(t); return (
diff --git a/special-pages/pages/new-tab/app/next-steps/components/NextStepsGroup.js b/special-pages/pages/new-tab/app/next-steps/components/NextStepsGroup.js index ee9b1ff5a..4e76c3460 100644 --- a/special-pages/pages/new-tab/app/next-steps/components/NextStepsGroup.js +++ b/special-pages/pages/new-tab/app/next-steps/components/NextStepsGroup.js @@ -1,13 +1,16 @@ import { h } from 'preact'; import cn from 'classnames'; import styles from './NextSteps.module.css'; -import { useTypedTranslation } from '../../types'; +import { useTypedTranslationWith } from '../../types'; import { NextStepsCard } from './NextStepsCard'; import { otherText } from '../nextsteps.data'; import { ShowHideButton } from '../../components/ShowHideButton'; import { useId } from 'preact/hooks'; /** + * @import enStrings from '../strings.json'; + * @import ntpStrings from '../../strings.json'; + * @typedef {enStrings & ntpStrings} strings * @typedef {import('../../../../../types/new-tab').Expansion} Expansion * @typedef {import('../../../../../types/new-tab').Animation} Animation * @typedef {import('../../../../../types/new-tab').NextStepsCards} NextStepsCards @@ -22,7 +25,7 @@ import { useId } from 'preact/hooks'; * @param {(id: string)=>void} props.dismiss */ export function NextStepsCardGroup({ types, expansion, toggle, action, dismiss }) { - const { t } = useTypedTranslation(); + const { t } = useTypedTranslationWith(/** @type {strings} */ ({})); const WIDGET_ID = useId(); const TOGGLE_ID = useId(); const alwaysShown = types.length > 2 ? types.slice(0, 2) : types; @@ -63,7 +66,7 @@ export function NextStepsCardGroup({ types, expansion, toggle, action, dismiss } } export function NextStepsBubbleHeader() { - const { t } = useTypedTranslation(); + const { t } = useTypedTranslationWith(/** @type {strings} */ ({})); const text = otherText.nextSteps_sectionTitle(t); return (
diff --git a/special-pages/pages/new-tab/app/next-steps/integrations-tests/next-steps.spec.js b/special-pages/pages/new-tab/app/next-steps/integrations-tests/next-steps.spec.js index b8feae889..3e3056101 100644 --- a/special-pages/pages/new-tab/app/next-steps/integrations-tests/next-steps.spec.js +++ b/special-pages/pages/new-tab/app/next-steps/integrations-tests/next-steps.spec.js @@ -52,7 +52,7 @@ test.describe('newtab NextSteps cards', () => { await expect(page.getByRole('button', { name: 'Try DuckPlayer' })).not.toBeVisible(); // expand the section - await page.getByLabel('Show more', { exact: true }).click(); + await page.getByLabel('Show More', { exact: true }).click(); await expect(page.locator('p').filter({ hasText: 'Block Cookie Pop-ups' })).toBeVisible(); await page.getByRole('button', { name: 'Try DuckPlayer' }).click(); diff --git a/special-pages/pages/new-tab/app/next-steps/nextsteps.data.js b/special-pages/pages/new-tab/app/next-steps/nextsteps.data.js index 1211c97e0..c29849147 100644 --- a/special-pages/pages/new-tab/app/next-steps/nextsteps.data.js +++ b/special-pages/pages/new-tab/app/next-steps/nextsteps.data.js @@ -1,5 +1,9 @@ +/** + * @import enStrings from "./strings.json" + * @import ntpStrings from "../strings.json" + */ export const variants = { - /** @param {(translationId: string) => string} t */ + /** @param {(translationId: keyof enStrings) => string} t */ bringStuff: (t) => ({ id: 'bringStuff', icon: 'Bring-Stuff', @@ -7,7 +11,7 @@ export const variants = { summary: t('nextSteps_bringStuff_summary'), actionText: t('nextSteps_bringStuff_actionText'), }), - /** @param {(translationId: string) => string} t */ + /** @param {(translationId: keyof enStrings) => string} t */ defaultApp: (t) => ({ id: 'defaultApp', icon: 'Default-App', @@ -15,7 +19,7 @@ export const variants = { summary: t('nextSteps_defaultApp_summary'), actionText: t('nextSteps_defaultApp_actionText'), }), - /** @param {(translationId: string) => string} t */ + /** @param {(translationId: keyof enStrings) => string} t */ blockCookies: (t) => ({ id: 'blockCookies', icon: 'Cookie-Pops', @@ -23,7 +27,7 @@ export const variants = { summary: t('nextSteps_blockCookies_summary'), actionText: t('nextSteps_blockCookies_actionText'), }), - /** @param {(translationId: string) => string} t */ + /** @param {(translationId: keyof enStrings) => string} t */ emailProtection: (t) => ({ id: 'emailProtection', icon: 'Email-Protection', @@ -31,7 +35,7 @@ export const variants = { summary: t('nextSteps_emailProtection_summary'), actionText: t('nextSteps_emailProtection_actionText'), }), - /** @param {(translationId: string) => string} t */ + /** @param {(translationId: keyof enStrings) => string} t */ duckplayer: (t) => ({ id: 'duckplayer', icon: 'Tube-Clean', @@ -39,7 +43,7 @@ export const variants = { summary: t('nextSteps_duckPlayer_summary'), actionText: t('nextSteps_duckPlayer_actionText'), }), - /** @param {(translationId: string) => string} t */ + /** @param {(translationId: keyof enStrings) => string} t */ addAppToDockMac: (t) => ({ id: 'addAppToDockMac', icon: 'Dock-Add-Mac', @@ -47,7 +51,7 @@ export const variants = { summary: t('nextSteps_addAppDockMac_summary'), actionText: t('nextSteps_addAppDockMac_actionText'), }), - /** @param {(translationId: string) => string} t */ + /** @param {(translationId: keyof enStrings) => string} t */ pinAppToTaskbarWindows: (t) => ({ id: 'pinAppToTaskbarWindows', icon: 'Dock-Add-Windows', @@ -58,10 +62,10 @@ export const variants = { }; export const otherText = { - /** @param {(translationId: string) => string} t */ + /** @param {(translationId: keyof ntpStrings) => string} t */ showMore: (t) => t('ntp_show_more'), - /** @param {(translationId: string) => string} t */ + /** @param {(translationId: keyof ntpStrings) => string} t */ showLess: (t) => t('ntp_show_less'), - /** @param {(translationId: string) => string} t */ + /** @param {(translationId: keyof enStrings) => string} t */ nextSteps_sectionTitle: (t) => t('nextSteps_sectionTitle'), }; diff --git a/special-pages/pages/new-tab/src/locales/en/newtab.json b/special-pages/pages/new-tab/app/next-steps/strings.json similarity index 54% rename from special-pages/pages/new-tab/src/locales/en/newtab.json rename to special-pages/pages/new-tab/app/next-steps/strings.json index 907189e7f..24095660c 100644 --- a/special-pages/pages/new-tab/src/locales/en/newtab.json +++ b/special-pages/pages/new-tab/app/next-steps/strings.json @@ -1,94 +1,4 @@ { - "smartling": { - "string_format": "icu", - "translate_paths": [ - { - "path": "*/title", - "key": "{*}/title", - "instruction": "*/note" - } - ] - }, - "ntp_show_less": { - "title": "Show less", - "note": "Text for the Expansion of a section on NTP" - }, - "ntp_show_more": { - "title": "Show more", - "note": "Text for the Expansion of a section on NTP" - }, - "ntp_dismiss": { - "title": "Dismiss", - "note": "Text for all dismiss buttons on NTP" - }, - "widgets_visibility_menu_title": { - "title": "Customize New Tab Page", - "note": "Heading text describing that there's a list of toggles for customizing the page layout." - }, - "trackerStatsMenuTitle": { - "title": "Privacy Stats", - "note": "Used as a toggle label in a page customization menu" - }, - "trackerStatsNoActivity": { - "title": "Tracking attempts blocked by DuckDuckGo appear here. Keep browsing to see how many we block.", - "note": "Placeholder for when we cannot report any blocked trackers yet" - }, - "trackerStatsNoRecent": { - "title": "No recent tracking activity", - "note": "Placeholder to indicate that nothing was blocked in the last 24 hours" - }, - "trackerStatsCountBlockedSingular": { - "title": "1 tracking attempt blocked", - "note": "The main headline indicating that 1 tracker was blocked" - }, - "trackerStatsCountBlockedPlural": { - "title": "{count} tracking attempts blocked", - "note": "The main headline indicating that more than 1 attempt has been blocked. Eg: '2 tracking attempts blocked'" - }, - "trackerStatsFeedCountBlockedSingular": { - "title": "1 attempt blocked by DuckDuckGo in the last 24 hours", - "note": "A summary description of how many tracking attempts where blocked, when only one exists." - }, - "trackerStatsFeedCountBlockedPlural": { - "title": "{count} attempts blocked by DuckDuckGo in the last 24 hours", - "note": "A summary description of how many tracking attempts where blocked, when there was more than 1. Eg: '1,028 attempts blocked by DuckDuckGo in the last 24 hours'" - }, - "trackerStatsToggleLabel": { - "title": "Show recent activity", - "note": "The aria-label text for a toggle button that shows the detailed activity feed" - }, - "trackerStatsHideLabel": { - "title": "Hide recent activity", - "note": "The aria-label text for a toggle button that hides the detailed activity feed" - }, - "trackerStatsOtherCompanyName": { - "title": "Other", - "note": "A placeholder to represent an aggregated count of entries, not present in the rest of the list. For example, 'Other: 200', which would mean 200 entries excluding the ones already shown" - }, - "favorites_show_less": { - "title": "Show less", - "note": "" - }, - "favorites_show_more": { - "title": "Show more ({count} remaining)", - "note": "" - }, - "favorites_menu_title": { - "title": "Favorites", - "note": "Used as a toggle label in a page customization menu" - }, - "updateNotification_updated_version": { - "title": "Browser Updated to version {version}.", - "note": "Text to indicate which new version was updated. `version` will be formatted like `1.22.0`" - }, - "updateNotification_whats_new": { - "title": "See what's new in this release.", - "note": "The `` tag represents a toggle" - }, - "updateNotification_dismiss_btn": { - "title": "Dismiss", - "note": "Button label text for an action that removes the widget from the screen." - }, "nextSteps_sectionTitle": { "title": "Next Steps", "note": "Text that goes in the Next Steps bubble above the first card" diff --git a/special-pages/pages/new-tab/app/privacy-stats/components/PrivacyStats.examples.js b/special-pages/pages/new-tab/app/privacy-stats/components/PrivacyStats.examples.js index 5322af677..3456b8e16 100644 --- a/special-pages/pages/new-tab/app/privacy-stats/components/PrivacyStats.examples.js +++ b/special-pages/pages/new-tab/app/privacy-stats/components/PrivacyStats.examples.js @@ -47,22 +47,12 @@ export const privacyStatsExamples = { }, 'stats.heading': { factory: () => ( - + ), }, 'stats.heading.none': { factory: () => ( - + ), }, }; diff --git a/special-pages/pages/new-tab/app/privacy-stats/components/PrivacyStats.js b/special-pages/pages/new-tab/app/privacy-stats/components/PrivacyStats.js index dbbdcd93c..9513bcfd5 100644 --- a/special-pages/pages/new-tab/app/privacy-stats/components/PrivacyStats.js +++ b/special-pages/pages/new-tab/app/privacy-stats/components/PrivacyStats.js @@ -1,6 +1,6 @@ -import { h } from 'preact'; +import { Fragment, h } from 'preact'; import styles from './PrivacyStats.module.css'; -import { useTypedTranslation } from '../../types.js'; +import { useTypedTranslationWith } from '../../types.js'; import { useContext, useState, useId, useCallback } from 'preact/hooks'; import { PrivacyStatsContext, PrivacyStatsProvider } from '../PrivacyStatsProvider.js'; import { useVisibility } from '../../widget-list/widget-config.provider.js'; @@ -11,6 +11,7 @@ import { DDG_STATS_OTHER_COMPANY_IDENTIFIER } from '../constants.js'; import { sortStatsForDisplay } from '../privacy-stats.utils.js'; /** + * @import enStrings from "../strings.json" * @typedef {import('../../../../../types/new-tab').TrackerCompany} TrackerCompany * @typedef {import('../../../../../types/new-tab').Expansion} Expansion * @typedef {import('../../../../../types/new-tab').Animation} Animation @@ -66,7 +67,6 @@ function PrivacyStatsConfigured({ parentRef, expansion, data, toggle }) { return (
void} props.onToggle * @param {import("preact").ComponentProps<'button'>} [props.buttonAttrs] */ -export function Heading({ expansion, trackerCompanies, totalCount, onToggle, buttonAttrs = {} }) { - const { t } = useTypedTranslation(); +export function Heading({ expansion, trackerCompanies, onToggle, buttonAttrs = {} }) { + const { t } = useTypedTranslationWith(/** @type {enStrings} */ ({})); const [formatter] = useState(() => new Intl.NumberFormat()); const recent = trackerCompanies.reduce((sum, item) => sum + item.count, 0); - const recentTitle = - recent === 1 - ? t('trackerStatsFeedCountBlockedSingular') - : t('trackerStatsFeedCountBlockedPlural', { count: formatter.format(recent) }); - const none = totalCount === 0; - const some = totalCount > 0; - const alltime = formatter.format(totalCount); - const alltimeTitle = totalCount === 1 ? t('trackerStatsCountBlockedSingular') : t('trackerStatsCountBlockedPlural', { count: alltime }); + const none = recent === 0; + const some = recent > 0; + const alltime = formatter.format(recent); + const alltimeTitle = recent === 1 ? t('stats_countBlockedSingular') : t('stats_countBlockedPlural', { count: alltime }); return (
Privacy Shield - {none &&

{t('trackerStatsNoRecent')}

} + {none &&

{t('stats_noRecent')}

} {some &&

{alltimeTitle}

} {recent > 0 && ( @@ -118,15 +113,13 @@ export function Heading({ expansion, trackerCompanies, totalCount, onToggle, but 'aria-pressed': expansion === 'expanded', }} onClick={onToggle} - text={expansion === 'expanded' ? t('trackerStatsHideLabel') : t('trackerStatsToggleLabel')} + text={expansion === 'expanded' ? t('stats_hideLabel') : t('stats_toggleLabel')} shape="round" /> )} -

- {recent === 0 && t('trackerStatsNoActivity')} - {recent > 0 && recentTitle} -

+ {recent === 0 &&

{t('stats_noActivity')}

} + {recent > 0 &&

{t('stats_feedCountBlockedPeriod')}

}
); } @@ -138,39 +131,51 @@ export function Heading({ expansion, trackerCompanies, totalCount, onToggle, but */ export function PrivacyStatsBody({ trackerCompanies, listAttrs = {} }) { - const { t } = useTypedTranslation(); + const { t } = useTypedTranslationWith(/** @type {enStrings} */ ({})); const [formatter] = useState(() => new Intl.NumberFormat()); const sorted = sortStatsForDisplay(trackerCompanies); const max = sorted[0]?.count ?? 0; + const [visible, setVisible] = useState(5); + const hasmore = sorted.length > visible; return ( -
    - {sorted.map((company) => { - const percentage = Math.min((company.count * 100) / max, 100); - const valueOrMin = Math.max(percentage, 10); - const inlineStyles = { - width: `${valueOrMin}%`, - }; - const countText = formatter.format(company.count); - // prettier-ignore - const displayName = company.displayName === DDG_STATS_OTHER_COMPANY_IDENTIFIER - ? t('trackerStatsOtherCompanyName') - : company.displayName; - return ( -
  • -
    -
    - - {displayName} + +
      + {sorted.slice(0, visible).map((company) => { + const percentage = Math.min((company.count * 100) / max, 100); + const valueOrMin = Math.max(percentage, 10); + const inlineStyles = { + width: `${valueOrMin}%`, + }; + const countText = formatter.format(company.count); + // prettier-ignore + const displayName = company.displayName + if (company.displayName === DDG_STATS_OTHER_COMPANY_IDENTIFIER) { + const otherText = t('stats_otherCount', { count: String(company.count) }); + return ( +
    • +
      {otherText}
      +
    • + ); + } + return ( +
    • +
      +
      + + {displayName} +
      + {countText} + +
      - {countText} - - -
    -
  • - ); - })} -
+ + ); + })} + + {hasmore && visible < sorted.length && } + {visible > 5 && visible === sorted.length && } + ); } @@ -181,10 +186,10 @@ export function PrivacyStatsBody({ trackerCompanies, listAttrs = {} }) { * whether to incur the side effects (data fetching). */ export function PrivacyStatsCustomized() { - const { t } = useTypedTranslation(); + const { t } = useTypedTranslationWith(/** @type {enStrings} */ ({})); const { visibility, id, toggle, index } = useVisibility(); - const title = t('trackerStatsMenuTitle'); + const title = t('stats_menuTitle'); useCustomizer({ title, id, icon: 'shield', toggle, visibility, index }); if (visibility === 'hidden') { diff --git a/special-pages/pages/new-tab/app/privacy-stats/components/PrivacyStats.module.css b/special-pages/pages/new-tab/app/privacy-stats/components/PrivacyStats.module.css index 68a6f2930..826d07dc0 100644 --- a/special-pages/pages/new-tab/app/privacy-stats/components/PrivacyStats.module.css +++ b/special-pages/pages/new-tab/app/privacy-stats/components/PrivacyStats.module.css @@ -19,9 +19,13 @@ display: grid; grid-template-columns: 1.5rem auto 2rem; gap: var(--sp-3); + grid-template-rows: auto; + grid-template-areas: 'icon title expander'; +} +.heading:has(.subtitle) { grid-template-rows: auto auto; grid-template-areas: - 'icon title expander' + 'icon title expander' 'label label label'; } .headingIcon { @@ -124,7 +128,9 @@ grid-template-columns: auto auto 60%; } } - +.textRow { + margin-top: 12px; +} .company { grid-area: company; display: flex; diff --git a/special-pages/pages/new-tab/app/privacy-stats/integration-tests/privacy-stats.spec.js b/special-pages/pages/new-tab/app/privacy-stats/integration-tests/privacy-stats.spec.js index 49f6f48eb..268bda1e1 100644 --- a/special-pages/pages/new-tab/app/privacy-stats/integration-tests/privacy-stats.spec.js +++ b/special-pages/pages/new-tab/app/privacy-stats/integration-tests/privacy-stats.spec.js @@ -21,7 +21,7 @@ test.describe('newtab privacy stats', () => { expect(await listItems.nth(1).textContent()).toBe('Google279'); expect(await listItems.nth(2).textContent()).toBe('Amazon67'); expect(await listItems.nth(3).textContent()).toBe('Google Ads2'); - expect(await listItems.nth(4).textContent()).toBe('Other210'); + expect(await listItems.nth(4).textContent()).toBe('210 attempts from other networks'); // show/hide await page.getByLabel('Hide recent activity').click(); diff --git a/special-pages/pages/new-tab/app/privacy-stats/mocks/stats.js b/special-pages/pages/new-tab/app/privacy-stats/mocks/stats.js index fa1225780..3737b306a 100644 --- a/special-pages/pages/new-tab/app/privacy-stats/mocks/stats.js +++ b/special-pages/pages/new-tab/app/privacy-stats/mocks/stats.js @@ -72,4 +72,52 @@ export const stats = { }, ], }, + many: { + totalCount: 890, + trackerCompanies: [ + { displayName: 'Google', count: 153 }, + { displayName: 'Microsoft', count: 69 }, + { displayName: 'Cloudflare', count: 65 }, + { displayName: 'Facebook', count: 61 }, + { displayName: 'ByteDance', count: 58 }, + { displayName: 'Adobe', count: 38 }, + { displayName: 'Magnite', count: 12 }, + { displayName: 'PubMatic', count: 10 }, + { displayName: 'Index Exchange', count: 10 }, + { displayName: 'OpenX', count: 10 }, + { displayName: 'Taboola', count: 9 }, + { displayName: 'comScore', count: 9 }, + { displayName: 'Akamai', count: 8 }, + { displayName: 'LiveIntent', count: 7 }, + { displayName: 'Criteo', count: 6 }, + { displayName: 'Verizon Media', count: 6 }, + { displayName: 'TripleLift', count: 5 }, + { displayName: 'YieldMo', count: 4 }, + { displayName: 'Neustar', count: 4 }, + { displayName: 'Oracle', count: 4 }, + { displayName: 'WPP', count: 3 }, + { displayName: 'Adform', count: 3 }, + { displayName: 'The Nielsen Company', count: 3 }, + { displayName: 'IPONWEB', count: 3 }, + { displayName: 'Kargo', count: 2 }, + { displayName: '__other__', count: 143 }, + { displayName: 'Sharethrough', count: 2 }, + { displayName: 'GumGum', count: 2 }, + { displayName: 'Media.net Advertising', count: 2 }, + { displayName: 'Amobee', count: 2 }, + { displayName: 'Improve Digital', count: 1 }, + { displayName: 'Smartadserver', count: 1 }, + { displayName: 'LoopMe', count: 1 }, + { displayName: 'Hotjar', count: 1 }, + { displayName: 'Amazon.com', count: 1 }, + { displayName: 'RTB House', count: 1 }, + { displayName: 'Sovrn Holdings', count: 1 }, + { displayName: 'Outbrain', count: 1 }, + { displayName: 'Conversant', count: 1 }, + { displayName: 'The Trade Desk', count: 1 }, + { displayName: 'RhythmOne', count: 1 }, + { displayName: 'Sonobi', count: 1 }, + { displayName: 'New Relic', count: 1 }, + ], + }, }; diff --git a/special-pages/pages/new-tab/app/privacy-stats/strings.json b/special-pages/pages/new-tab/app/privacy-stats/strings.json new file mode 100644 index 000000000..b2229efad --- /dev/null +++ b/special-pages/pages/new-tab/app/privacy-stats/strings.json @@ -0,0 +1,50 @@ +{ + "stats_menuTitle": { + "title": "Blocked Tracking Attempts", + "note": "Used as a label in a customization menu" + }, + "stats_noActivity": { + "title": "Blocked tracking attempts will appear here. Keep browsing to see how many we block.", + "note": "Placeholder for when we cannot report any blocked trackers yet" + }, + "stats_noRecent": { + "title": "No recent tracking activity", + "note": "Placeholder to indicate that no tracking activity was blocked in the last 7 days" + }, + "stats_countBlockedSingular": { + "title": "1 tracking attempt blocked", + "note": "The main headline indicating that a single tracker was blocked" + }, + "stats_countBlockedPlural": { + "title": "{count} tracking attempts blocked", + "note": "The main headline indicating that more than 1 attempt has been blocked. Eg: '2 tracking attempts blocked'" + }, + "stats_feedCountBlockedSingular": { + "title": "1 attempt blocked by DuckDuckGo in the last 7 days", + "note": "A summary description of how many tracking attempts where blocked, when only one exists." + }, + "stats_feedCountBlockedPeriod": { + "title": "Past 7 days", + "note": "A summary description indicating the time period of the blocked tracking attempts, which is the past 7 days." + }, + "stats_feedCountBlockedPlural": { + "title": "{count} tracking attempts blocked", + "note": "A summary description of how many tracking attempts were blocked by DuckDuckGo in the last 7 days when there is more than one. E.g., '1,028 tracking attempts blocked." + }, + "stats_toggleLabel": { + "title": "Show recent activity", + "note": "The aria-label text for a toggle button that shows the detailed activity feed" + }, + "stats_hideLabel": { + "title": "Hide recent activity", + "note": "The aria-label text for a toggle button that hides the detailed activity feed" + }, + "stats_otherCompanyName": { + "title": "Other", + "note": "A placeholder to represent an aggregated count of entries, not present in the rest of the list. For example, 'Other: 200', which would mean 200 entries excluding the ones already shown" + }, + "stats_otherCount": { + "title": "{count} attempts from other networks", + "note": "An aggregated count of blocked entries not present in the main list. For example, '200 attempts from other networks'" + } +} diff --git a/special-pages/pages/new-tab/app/strings.json b/special-pages/pages/new-tab/app/strings.json new file mode 100644 index 000000000..9ec797ad4 --- /dev/null +++ b/special-pages/pages/new-tab/app/strings.json @@ -0,0 +1,18 @@ +{ + "ntp_show_less": { + "title": "Show Less", + "note": "Button that reduces the number of items or content displayed." + }, + "ntp_show_more": { + "title": "Show More", + "note": "Button that increases the number of items or content displayed." + }, + "ntp_dismiss": { + "title": "Dismiss", + "note": "Button that closes or hides the current popup or notification." + }, + "widgets_visibility_menu_title": { + "title": "Customize New Tab Page", + "note": "Heading text describing that there's a list of toggles for customizing the page layout." + } +} diff --git a/special-pages/pages/new-tab/app/types.js b/special-pages/pages/new-tab/app/types.js index dd0acda24..c1a3d42e1 100644 --- a/special-pages/pages/new-tab/app/types.js +++ b/special-pages/pages/new-tab/app/types.js @@ -1,10 +1,9 @@ import { useContext } from 'preact/hooks'; import { TranslationContext } from '../../../shared/components/TranslationsProvider.js'; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import json from '../src/locales/en/newtab.json'; import { createContext } from 'preact'; /** + * @import json from './strings.json'; * @import { InitialSetupResponse } from "../../../types/new-tab.js"; */ @@ -18,6 +17,19 @@ export function useTypedTranslation() { }; } +/** + * This is a wrapper to only allow keys from the default translation file + contextual ones + * @template {Record} T + * @param {T} context + * @returns {{ t: (key: keyof json|keyof T, replacements?: Record) => string }} + */ +export function useTypedTranslationWith(context) { + return { + /** @type {any} */ + t: useContext(TranslationContext).t, + }; +} + export const MessagingContext = createContext(/** @type {import("../src/js/index.js").NewTabPage} */ ({})); export const useMessaging = () => useContext(MessagingContext); export const TelemetryContext = createContext( diff --git a/special-pages/pages/new-tab/app/update-notification/components/UpdateNotification.js b/special-pages/pages/new-tab/app/update-notification/components/UpdateNotification.js index 9aeef6a6e..e7d303f9b 100644 --- a/special-pages/pages/new-tab/app/update-notification/components/UpdateNotification.js +++ b/special-pages/pages/new-tab/app/update-notification/components/UpdateNotification.js @@ -3,7 +3,7 @@ import cn from 'classnames'; import styles from './UpdateNotification.module.css'; import { useContext, useId, useRef } from 'preact/hooks'; import { UpdateNotificationContext } from '../UpdateNotificationProvider.js'; -import { useTypedTranslation } from '../../types.js'; +import { useTypedTranslationWith } from '../../types.js'; import { Trans } from '../../../../../shared/components/TranslationsProvider.js'; import { DismissButton } from '../../components/DismissButton'; @@ -28,7 +28,7 @@ export function UpdateNotification({ notes, dismiss, version }) { export function WithNotes({ notes, version }) { const id = useId(); const ref = useRef(/** @type {HTMLDetailsElement|null} */ (null)); - const { t } = useTypedTranslation(); + const { t } = useTypedTranslationWith(/** @type {import("../strings.json")} */ ({})); const inlineLink = ( {t('updateNotification_updated_version', { version })}

; } diff --git a/special-pages/pages/new-tab/app/update-notification/strings.json b/special-pages/pages/new-tab/app/update-notification/strings.json new file mode 100644 index 000000000..90e4f28b0 --- /dev/null +++ b/special-pages/pages/new-tab/app/update-notification/strings.json @@ -0,0 +1,14 @@ +{ + "updateNotification_updated_version": { + "title": "Browser Updated to version {version}.", + "note": "Text to indicate which new version was updated. `{version}` will be formatted like `1.22.0`" + }, + "updateNotification_whats_new": { + "title": "See
what's new in this release.", + "note": "The `` tag represents a clickable link, please preserve it." + }, + "updateNotification_dismiss_btn": { + "title": "Dismiss", + "note": "Button label text for an action that removes the widget from the screen." + } +} diff --git a/special-pages/pages/new-tab/integration-tests/new-tab.spec.js b/special-pages/pages/new-tab/integration-tests/new-tab.spec.js index 0c6b66b1b..cf86a107c 100644 --- a/special-pages/pages/new-tab/integration-tests/new-tab.spec.js +++ b/special-pages/pages/new-tab/integration-tests/new-tab.spec.js @@ -11,7 +11,7 @@ test.describe('newtab widgets', () => { await page.getByRole('button', { name: 'Customize' }).click(); // hide - await page.locator('label').filter({ hasText: 'Privacy Stats' }).click(); + await page.locator('label').filter({ hasText: 'Blocked Tracking Attempts' }).click(); // debounced await page.waitForTimeout(500); @@ -42,10 +42,10 @@ test.describe('newtab widgets', () => { await page.getByRole('button', { name: 'Customize' }).click(); // hide - await page.locator('label').filter({ hasText: 'Privacy Stats' }).uncheck(); + await page.locator('label').filter({ hasText: 'Blocked Tracking Attempts' }).uncheck(); // show - await page.locator('label').filter({ hasText: 'Privacy Stats' }).check(); + await page.locator('label').filter({ hasText: 'Blocked Tracking Attempts' }).check(); // debounced await page.waitForTimeout(500); @@ -89,7 +89,7 @@ test.describe('newtab widgets', () => { }, { id: 'privacyStats', - title: 'Privacy Stats', + title: 'Blocked Tracking Attempts', }, ], }, diff --git a/special-pages/pages/new-tab/src/js/index.js b/special-pages/pages/new-tab/src/js/index.js index 89f9c1c7c..12d911e70 100644 --- a/special-pages/pages/new-tab/src/js/index.js +++ b/special-pages/pages/new-tab/src/js/index.js @@ -9,7 +9,7 @@ import { init } from '../../app/index.js'; import { createTypedMessages } from '@duckduckgo/messaging'; import { createSpecialPageMessaging } from '../../../../shared/create-special-page-messaging'; import { Environment } from '../../../../shared/environment.js'; -import { mockTransport } from './mock-transport.js'; +import { mockTransport } from '../../app/mock-transport.js'; import { install } from '../../app/telemetry/telemetry.js'; export class NewTabPage { diff --git a/special-pages/pages/new-tab/src/locales/en/.gitignore b/special-pages/pages/new-tab/src/locales/en/.gitignore new file mode 100644 index 000000000..72e8ffc0d --- /dev/null +++ b/special-pages/pages/new-tab/src/locales/en/.gitignore @@ -0,0 +1 @@ +* diff --git a/special-pages/translations.mjs b/special-pages/translations.mjs new file mode 100644 index 000000000..a8db8e336 --- /dev/null +++ b/special-pages/translations.mjs @@ -0,0 +1,55 @@ +import { isLaunchFile } from '../scripts/script-utils.js'; +import { readdir } from 'fs/promises'; +import { join, basename } from 'node:path'; +import { readFileSync, writeFileSync } from 'node:fs'; + +const paths = ['pages/new-tab']; +const base = { + smartling: { + string_format: 'icu', + translate_paths: [ + { + path: '*/title', + key: '{*}/title', + instruction: '*/note', + }, + ], + }, +}; + +if (isLaunchFile(import.meta.url)) { + for (const path of paths) { + await processPage(path); + } +} + +/** + * @param {string} path + */ +async function processPage(path) { + const targetName = basename(path); + const outputFile = `${path}/src/locales/en/${targetName}.json`; + const dirents = await readdir(path, { withFileTypes: true, recursive: true }); + const rawEntries = dirents + .filter((entry) => entry.isFile() && entry.name === 'strings.json') + .map((entry) => { + const path = join(entry.parentPath, entry.name); + const raw = readFileSync(path, 'utf8'); + const json = JSON.parse(raw); + return { + path, + raw, + json, + }; + }); + + for (const rawEntry of rawEntries) { + console.log(`✅ adding ${rawEntry.path} to ${targetName}.json`); + } + + const entries = rawEntries.map((entry) => Object.entries(entry.json)).flat(); + const object = Object.fromEntries(entries); + const withBase = { ...base, ...object }; + const string = JSON.stringify(withBase, null, 2); + writeFileSync(outputFile, string); +}