Bug 1820259 - Implement the translation language download preferences; r=nordzilla,flod,mconley

Differential Revision: https://phabricator.services.mozilla.com/D176189
This commit is contained in:
Greg Tatum 2023-04-26 19:12:17 +00:00
parent 23de35b953
commit cf91f08437
7 changed files with 497 additions and 8 deletions

View file

@ -355,6 +355,7 @@
data-l10n-args='{"localeName": "und"}'
preference="intl.regional_prefs.use_os_locales"/>
<!-- TODO (Bug 1817084) This older implementation will be removed soon -->
<hbox id="translationBox" hidden="true">
<hbox align="center" flex="1">
<checkbox id="translate" preference="browser.translation.detectLanguage"
@ -384,6 +385,25 @@
<checkbox id="checkSpelling"
data-l10n-id="check-user-spelling"
preference="layout.spellcheckDefault"/>
<!-- Translations -->
<vbox id="translationsGroup" hidden="true" data-subcategory="translations">
<label><html:h2 data-l10n-id="translations-manage-header"/></label>
<description data-l10n-id="translations-manage-description"/>
<vbox>
<html:div id="translations-manage-install-list" hidden="true">
<hbox class="translations-manage-language">
<label data-l10n-id="translations-manage-all-language"></label>
<button id="translations-manage-install-all"
data-l10n-id="translations-manage-download-button"></button>
<button id="translations-manage-delete-all"
data-l10n-id="translations-manage-delete-button"></button>
</hbox>
<!-- The downloadable languages will be listed here. -->
</html:div>
<description id="translations-manage-error" hidden="true"></description>
</vbox>
</vbox>
</groupbox>
<!-- Files and Applications -->

View file

@ -327,6 +327,8 @@ var gMainPane = {
// listener for future menu changes.
gMainPane.initDefaultZoomValues();
gMainPane.initTranslations();
if (
Services.prefs.getBoolPref(
"media.videocontrols.picture-in-picture.enabled"
@ -995,6 +997,424 @@ var gMainPane = {
document.getElementById("zoomBox").hidden = false;
},
/**
* Initialize the translations view.
*/
async initTranslations() {
if (!Services.prefs.getBoolPref("browser.translations.enable")) {
return;
}
/**
* Which phase a language download is in.
*
* @typedef {"downloaded" | "loading" | "uninstalled"} DownloadPhase
*/
// Immediately show the group so that the async load of the component does
// not cause the layout to jump. The group will be empty initially.
document.getElementById("translationsGroup").hidden = false;
class TranslationsState {
/**
* The fully initialized state.
*
* @param {TranslationsActor} translationsActor
* @param {Object} supportedLanguages
* @param {Array<{ langTag: string, displayName: string}} languageList
* @param {Map<string, DownloadPhase>} downloadPhases
*/
constructor(
translationsActor,
supportedLanguages,
languageList,
downloadPhases
) {
this.translationsActor = translationsActor;
this.supportedLanguages = supportedLanguages;
this.languageList = languageList;
this.downloadPhases = downloadPhases;
}
/**
* Handles all of the async initialization logic.
*/
static async create() {
const translationsActor = window.windowGlobalChild.getActor(
"Translations"
);
const supportedLanguages = await translationsActor.getSupportedLanguages();
const languageList = TranslationsState.getLanguageList(
supportedLanguages
);
const downloadPhases = await TranslationsState.createDownloadPhases(
translationsActor,
languageList
);
if (supportedLanguages.languagePairs.length === 0) {
throw new Error(
"The supported languages list was empty. RemoteSettings may not be available at the moment."
);
}
return new TranslationsState(
translationsActor,
supportedLanguages,
languageList,
downloadPhases
);
}
/**
* Create a unique list of languages, sorted by the display name.
*
* @param {Object} supportedLanguages
* @returns {Array<{ langTag: string, displayName: string}}
*/
static getLanguageList(supportedLanguages) {
const displayNames = new Map();
for (const languages of [
supportedLanguages.fromLanguages,
supportedLanguages.toLanguages,
]) {
for (const { langTag, displayName } of languages) {
displayNames.set(langTag, displayName);
}
}
let appLangTag = new Intl.Locale(Services.locale.appLocaleAsBCP47)
.language;
// Don't offer to download the app's language.
displayNames.delete(appLangTag);
// Sort the list of languages by the display names.
return [...displayNames.entries()]
.map(([langTag, displayName]) => ({
langTag,
displayName,
}))
.sort((a, b) => a.displayName.localeCompare(b.displayName));
}
/**
* Determine the download phase of each language file.
*
* @param {TranslationsChild} translationsActor
* @param {Array<{ langTag: string, displayName: string}} languageList.
* @returns {Map<string, DownloadPhase>} Map the language tag to whether it is downloaded.
*/
static async createDownloadPhases(translationsActor, languageList) {
const downloadPhases = new Map();
for (const { langTag } of languageList) {
downloadPhases.set(
langTag,
(await translationsActor.hasAllFilesForLanguage(langTag))
? "downloaded"
: "uninstalled"
);
}
return downloadPhases;
}
}
class TranslationsView {
/** @type {Map<string, XULButton>} */
deleteButtons = new Map();
/** @type {Map<string, XULButton>} */
downloadButtons = new Map();
/**
* @param {TranslationsState} state
*/
constructor(state) {
this.state = state;
this.elements = {
installList: document.getElementById(
"translations-manage-install-list"
),
installAll: document.getElementById(
"translations-manage-install-all"
),
deleteAll: document.getElementById("translations-manage-delete-all"),
error: document.getElementById("translations-manage-error"),
};
this.setup();
}
setup() {
this.buildLanguageList();
this.elements.installAll.addEventListener(
"command",
this.handleInstallAll
);
this.elements.deleteAll.addEventListener(
"command",
this.handleDeleteAll
);
}
handleInstallAll = async () => {
this.hideError();
this.disableButtons(true);
try {
await this.state.translationsActor.downloadAllFiles();
this.markAllDownloadPhases("downloaded");
} catch (error) {
TranslationsView.showError(
"translations-manage-error-download",
error
);
await this.reloadDownloadPhases();
this.updateAllButtons();
}
this.disableButtons(false);
};
handleDeleteAll = async () => {
this.hideError();
this.disableButtons(true);
try {
await this.state.translationsActor.deleteAllLanguageFiles();
this.markAllDownloadPhases("uninstalled");
} catch (error) {
TranslationsView.showError("translations-manage-error-delete", error);
// The download phases are invalidated with the error and must be reloaded.
await this.reloadDownloadPhases();
console.error(error);
}
this.disableButtons(false);
};
/**
* @param {string} langTag
* @returns {Function}
*/
getDownloadButtonHandler(langTag) {
return async () => {
this.hideError();
this.updateDownloadPhase(langTag, "loading");
try {
await this.state.translationsActor.downloadLanguageFiles(langTag);
this.updateDownloadPhase(langTag, "downloaded");
} catch (error) {
TranslationsView.showError(
"translations-manage-error-download",
error
);
this.updateDownloadPhase(langTag, "uninstalled");
}
};
}
/**
* @param {string} langTag
* @returns {Function}
*/
getDeleteButtonHandler(langTag) {
return async () => {
this.hideError();
this.updateDownloadPhase(langTag, "loading");
try {
await this.state.translationsActor.deleteLanguageFiles(langTag);
this.updateDownloadPhase(langTag, "uninstalled");
} catch (error) {
TranslationsView.showError(
"translations-manage-error-delete",
error
);
// The download phases are invalidated with the error and must be reloaded.
await this.reloadDownloadPhases();
}
};
}
buildLanguageList() {
const listFragment = document.createDocumentFragment();
for (const { langTag, displayName } of this.state.languageList) {
const hboxRow = document.createXULElement("hbox");
hboxRow.classList.add("translations-manage-language");
const languageLabel = document.createXULElement("label");
languageLabel.textContent = displayName; // The display name is already localized.
const downloadButton = document.createXULElement("button");
const deleteButton = document.createXULElement("button");
downloadButton.addEventListener(
"command",
this.getDownloadButtonHandler(langTag)
);
deleteButton.addEventListener(
"command",
this.getDeleteButtonHandler(langTag)
);
document.l10n.setAttributes(
downloadButton,
"translations-manage-download-button"
);
document.l10n.setAttributes(
deleteButton,
"translations-manage-delete-button"
);
downloadButton.hidden = true;
deleteButton.hidden = true;
this.deleteButtons.set(langTag, deleteButton);
this.downloadButtons.set(langTag, downloadButton);
hboxRow.appendChild(languageLabel);
hboxRow.appendChild(downloadButton);
hboxRow.appendChild(deleteButton);
listFragment.appendChild(hboxRow);
}
this.updateAllButtons();
this.elements.installList.appendChild(listFragment);
this.elements.installList.hidden = false;
}
/**
* Update the DownloadPhase for a single langTag.
* @param {string} langTag
* @param {DownloadPhase} downloadPhase
*/
updateDownloadPhase(langTag, downloadPhase) {
this.state.downloadPhases.set(langTag, downloadPhase);
this.updateButton(langTag, downloadPhase);
this.updateHeaderButtons();
}
/**
* Recreates the download map when the state is invalidated.
*/
async reloadDownloadPhases() {
this.state.downloadPhases = await TranslationsState.createDownloadPhases(
this.state.translationsActor,
this.state.languageList
);
this.updateAllButtons();
}
/**
* Set all the downloads.
* @param {DownloadPhase} downloadPhase
*/
markAllDownloadPhases(downloadPhase) {
const { downloadPhases } = this.state;
for (const key of downloadPhases.keys()) {
downloadPhases.set(key, downloadPhase);
}
this.updateAllButtons();
}
/**
* If all languages are downloaded, or no languages are downloaded then
* the visibility of the buttons need to change.
*/
updateHeaderButtons() {
let allDownloaded = true;
let allUninstalled = true;
for (const downloadPhase of this.state.downloadPhases.values()) {
if (downloadPhase === "loading") {
// Don't count loading towards this calculation.
continue;
}
allDownloaded &&= downloadPhase === "downloaded";
allUninstalled &&= downloadPhase === "uninstalled";
}
this.elements.installAll.hidden = allDownloaded;
this.elements.deleteAll.hidden = allUninstalled;
}
/**
* Update the buttons according to their download state.
*/
updateAllButtons() {
this.updateHeaderButtons();
for (const [langTag, downloadPhase] of this.state.downloadPhases) {
this.updateButton(langTag, downloadPhase);
}
}
/**
* @param {string} langTag
* @param {DownloadPhase} downloadPhase
*/
updateButton(langTag, downloadPhase) {
const downloadButton = this.downloadButtons.get(langTag);
const deleteButton = this.deleteButtons.get(langTag);
switch (downloadPhase) {
case "downloaded":
downloadButton.hidden = true;
deleteButton.hidden = false;
downloadButton.removeAttribute("disabled");
break;
case "uninstalled":
downloadButton.hidden = false;
deleteButton.hidden = true;
downloadButton.removeAttribute("disabled");
break;
case "loading":
downloadButton.hidden = false;
deleteButton.hidden = true;
downloadButton.setAttribute("disabled", true);
break;
}
}
/**
* @param {boolean} isDisabled
*/
disableButtons(isDisabled) {
this.elements.installAll.disabled = isDisabled;
this.elements.deleteAll.disabled = isDisabled;
for (const button of this.downloadButtons.values()) {
button.disabled = isDisabled;
}
for (const button of this.deleteButtons.values()) {
button.disabled = isDisabled;
}
}
/**
* This method is static in case an error happens during the creation of the
* TranslationsState.
*
* @param {string} l10nId
* @param {Error} error
*/
static showError(l10nId, error) {
console.error(error);
const errorMessage = document.getElementById(
"translations-manage-error"
);
errorMessage.hidden = false;
document.l10n.setAttributes(errorMessage, l10nId);
}
hideError() {
this.elements.error.hidden = true;
}
}
TranslationsState.create().then(
state => {
new TranslationsView(state);
},
error => {
// This error can happen when a user is not connected to the internet, or
// RemoteSettings is down for some reason.
TranslationsView.showError("translations-manage-error-list", error);
}
);
},
initPrimaryBrowserLanguageUI() {
// Enable telemetry.
Services.telemetry.setEventRecordingEnabled(

View file

@ -33,6 +33,7 @@
<link rel="localization" href="browser/preferences/fonts.ftl"/>
<link rel="localization" href="browser/preferences/moreFromMozilla.ftl"/>
<link rel="localization" href="browser/preferences/preferences.ftl"/>
<link rel="localization" href="locales-preview/translations.ftl"/>
<link rel="localization" href="toolkit/branding/accounts.ftl"/>
<link rel="localization" href="toolkit/branding/brandings.ftl"/>
<link rel="localization" href="toolkit/featuregates/features.ftl"/>

View file

@ -153,6 +153,7 @@ add_task(async function search_for_password_show_passwordGroup() {
"browsingCategory",
"networkProxyCategory",
"dataMigrationGroup",
"translationsGroup",
];
// Only visible for non-MSIX builds
if (

View file

@ -28,3 +28,14 @@ translations-panel-restore-header = Change the language?
# $toLanguage (string) - The target language of the translation.
translations-panel-restore-label = The page is being translated from { $fromLanguage } to { $toLanguage }.
translations-panel-restore-button = Restore the page
## Firefox Translations language management in about:preferences.
translations-manage-header = Translations
translations-manage-description = Download languages for offline translation.
translations-manage-all-language = All languages
translations-manage-download-button = Download
translations-manage-delete-button = Delete
translations-manage-error-download = There was a problem downloading the language files. Please try again.
translations-manage-error-delete = There was an error deleting the language files. Please try again.
translations-manage-error-list = Failed to get the list of available languages for translation. Refresh the page to try again.

View file

@ -1455,6 +1455,37 @@ richlistitem .text-link:hover {
margin-top: 0.4em;
}
#translations-manage-install-list {
height: 220px;
overflow: scroll;
background-color: var(--in-content-box-background);
border: 1px solid var(--in-content-box-border-color);
border-radius: 4px;
resize: vertical;
margin: 4px 0;
}
.translations-manage-language:first-child {
border-bottom: 1px solid var(--in-content-box-border-color);
padding: 8px;
margin-bottom: 8px;
}
.translations-manage-language {
align-items: center;
padding: 0 8px;
}
.translations-manage-language label {
flex: 1;
margin: 0 15px;
}
#translations-manage-error {
color: var(--in-content-error-text-color);
margin: 16px 0;
}
/* Platform-specific tweaks & overrides */
@media (-moz-platform: macos) {

View file

@ -862,12 +862,15 @@ export class TranslationsParent extends JSWindowActorParent {
const client = this.#getTranslationModelsRemoteClient();
const isForDeletion = true;
return Promise.all(
Array.from(await this.getMatchedRecords(language, isForDeletion)).map(
record => {
lazy.console.log("Deleting record", record);
return client.attachments.deleteDownloaded(record);
}
)
Array.from(
await this.getRecordsForTranslatingToAndFromAppLanguage(
language,
isForDeletion
)
).map(record => {
lazy.console.log("Deleting record", record);
return client.attachments.deleteDownloaded(record);
})
);
}
@ -881,7 +884,9 @@ export class TranslationsParent extends JSWindowActorParent {
const queue = [];
for (const record of await this.getMatchedRecords(language)) {
for (const record of await this.getRecordsForTranslatingToAndFromAppLanguage(
language
)) {
const download = () => {
lazy.console.log("Downloading record", record.name, record.id);
return client.attachments.download(record);
@ -941,7 +946,7 @@ export class TranslationsParent extends JSWindowActorParent {
*/
async hasAllFilesForLanguage(requestedLanguage) {
const client = this.#getTranslationModelsRemoteClient();
for (const record of await this.getMatchedRecords(
for (const record of await this.getRecordsForTranslatingToAndFromAppLanguage(
requestedLanguage,
true
)) {