mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-23 17:26:42 +00:00
Save user language setting to backend (#2784)
* Save user language setting to backend * Remove hass.selectedLanguage * Lint * Address code review comment * Refactoring translation * Code review * Add back selectedLanguage and local app storage * Move getTranslations to data/frontend.ts * Fix mock hass * Rewrite translations-mixin * revert no need changes * Final tweak
This commit is contained in:
parent
ed9dff99d3
commit
f809bf0550
38
src/data/frontend.ts
Normal file
38
src/data/frontend.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { HomeAssistant } from "../types";
|
||||
|
||||
export interface FrontendUserData {
|
||||
language: string;
|
||||
}
|
||||
|
||||
export const fetchFrontendUserData = async (
|
||||
hass: HomeAssistant,
|
||||
key: string
|
||||
): Promise<FrontendUserData> => {
|
||||
const result = await hass.callWS<{ value: FrontendUserData }>({
|
||||
type: "frontend/get_user_data",
|
||||
key,
|
||||
});
|
||||
return result.value;
|
||||
};
|
||||
|
||||
export const saveFrontendUserData = async (
|
||||
hass: HomeAssistant,
|
||||
key: string,
|
||||
value: FrontendUserData
|
||||
): Promise<void> =>
|
||||
hass.callWS<void>({
|
||||
type: "frontend/set_user_data",
|
||||
key,
|
||||
value,
|
||||
});
|
||||
|
||||
export const getHassTranslations = async (
|
||||
hass: HomeAssistant,
|
||||
language: string
|
||||
): Promise<{}> => {
|
||||
const result = await hass.callWS<{ resources: {} }>({
|
||||
type: "frontend/get_translations",
|
||||
language,
|
||||
});
|
||||
return result.resources;
|
||||
};
|
@ -7,7 +7,7 @@ import { demoPanels } from "./demo_panels";
|
||||
import { getEntity, Entity } from "./entity";
|
||||
import { HomeAssistant } from "../types";
|
||||
import { HassEntities } from "home-assistant-js-websocket";
|
||||
import { getActiveTranslation } from "../util/hass-translation";
|
||||
import { getLocalLanguage } from "../util/hass-translation";
|
||||
import { translationMetadata } from "../resources/translations-metadata";
|
||||
|
||||
const ensureArray = <T>(val: T | T[]): T[] =>
|
||||
@ -87,6 +87,8 @@ export const provideHass = (
|
||||
);
|
||||
});
|
||||
|
||||
const localLanguage = getLocalLanguage();
|
||||
|
||||
const hassObj: MockHomeAssistant = {
|
||||
// Home Assistant properties
|
||||
auth: {} as any,
|
||||
@ -134,7 +136,8 @@ export const provideHass = (
|
||||
},
|
||||
panelUrl: "lovelace",
|
||||
|
||||
language: getActiveTranslation(),
|
||||
language: localLanguage,
|
||||
selectedLanguage: localLanguage,
|
||||
resources: null as any,
|
||||
localize: () => "",
|
||||
|
||||
|
@ -12,7 +12,7 @@ import LocalizeMixin from "../../mixins/localize-mixin";
|
||||
import EventsMixin from "../../mixins/events-mixin";
|
||||
|
||||
import { getState } from "../../util/ha-pref-storage";
|
||||
import { getActiveTranslation } from "../../util/hass-translation";
|
||||
import { getLocalLanguage } from "../../util/hass-translation";
|
||||
import { fetchWithAuth } from "../../util/fetch-with-auth";
|
||||
import hassCallApi from "../../util/hass-call-api";
|
||||
import { subscribePanels } from "../../data/ws-panels";
|
||||
@ -49,7 +49,7 @@ export default (superClass) =>
|
||||
user: null,
|
||||
panelUrl: this._panelUrl,
|
||||
|
||||
language: getActiveTranslation(),
|
||||
language: getLocalLanguage(),
|
||||
// If resources are already loaded, don't discard them
|
||||
resources: (this.hass && this.hass.resources) || null,
|
||||
localize: () => "",
|
||||
|
@ -24,7 +24,7 @@ export default (superClass: Constructor<LitElement & HassBaseEl>) =>
|
||||
super.firstUpdated(changedProps);
|
||||
this.addEventListener("hass-dock-sidebar", (ev) => {
|
||||
this._updateHass({ dockedSidebar: ev.detail.dock });
|
||||
storeState(this.hass);
|
||||
storeState(this.hass!);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
@ -1,32 +1,42 @@
|
||||
import applyThemesOnElement from "../../common/dom/apply_themes_on_element";
|
||||
import { storeState } from "../../util/ha-pref-storage";
|
||||
import { subscribeThemes } from "../../data/ws-themes";
|
||||
import { Constructor, LitElement } from "lit-element";
|
||||
import { HassBaseEl } from "./hass-base-mixin";
|
||||
import { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
|
||||
export default (superClass) =>
|
||||
declare global {
|
||||
// for add event listener
|
||||
interface HTMLElementEventMap {
|
||||
settheme: HASSDomEvent<string>;
|
||||
}
|
||||
}
|
||||
|
||||
export default (superClass: Constructor<LitElement & HassBaseEl>) =>
|
||||
class extends superClass {
|
||||
firstUpdated(changedProps) {
|
||||
protected firstUpdated(changedProps) {
|
||||
super.firstUpdated(changedProps);
|
||||
this.addEventListener("settheme", (ev) => {
|
||||
this._updateHass({ selectedTheme: ev.detail });
|
||||
this._applyTheme();
|
||||
storeState(this.hass);
|
||||
storeState(this.hass!);
|
||||
});
|
||||
}
|
||||
|
||||
hassConnected() {
|
||||
protected hassConnected() {
|
||||
super.hassConnected();
|
||||
|
||||
subscribeThemes(this.hass.connection, (themes) => {
|
||||
subscribeThemes(this.hass!.connection, (themes) => {
|
||||
this._updateHass({ themes });
|
||||
this._applyTheme();
|
||||
});
|
||||
}
|
||||
|
||||
_applyTheme() {
|
||||
private _applyTheme() {
|
||||
applyThemesOnElement(
|
||||
document.documentElement,
|
||||
this.hass.themes,
|
||||
this.hass.selectedTheme,
|
||||
this.hass!.themes,
|
||||
this.hass!.selectedTheme,
|
||||
true
|
||||
);
|
||||
}
|
@ -1,11 +1,16 @@
|
||||
import { translationMetadata } from "../../resources/translations-metadata";
|
||||
import { getTranslation } from "../../util/hass-translation";
|
||||
import { storeState } from "../../util/ha-pref-storage";
|
||||
import {
|
||||
getTranslation,
|
||||
getLocalLanguage,
|
||||
getUserLanguage,
|
||||
} from "../../util/hass-translation";
|
||||
import { Constructor, LitElement } from "lit-element";
|
||||
import { HassBaseEl } from "./hass-base-mixin";
|
||||
import { computeLocalize } from "../../common/translations/localize";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import { HomeAssistant } from "../../types";
|
||||
import { saveFrontendUserData, getHassTranslations } from "../../data/frontend";
|
||||
import { storeState } from "../../util/ha-pref-storage";
|
||||
|
||||
/*
|
||||
* superClass needs to contain `this.hass` and `this._updateHass`.
|
||||
@ -16,60 +21,86 @@ export default (superClass: Constructor<LitElement & HassBaseEl>) =>
|
||||
protected firstUpdated(changedProps) {
|
||||
super.firstUpdated(changedProps);
|
||||
this.addEventListener("hass-language-select", (e) =>
|
||||
this._selectLanguage(e)
|
||||
this._selectLanguage((e as CustomEvent).detail.language, true)
|
||||
);
|
||||
this._loadResources();
|
||||
this._loadCoreTranslations(getLocalLanguage());
|
||||
}
|
||||
|
||||
protected hassConnected() {
|
||||
super.hassConnected();
|
||||
this._loadBackendTranslations();
|
||||
this.style.direction = computeRTL(this.hass!) ? "rtl" : "ltr";
|
||||
getUserLanguage(this.hass!).then((language) => {
|
||||
if (language && this.hass!.language !== language) {
|
||||
// We just get language from backend, no need to save back
|
||||
this._selectLanguage(language, false);
|
||||
}
|
||||
});
|
||||
this._applyTranslations(this.hass!);
|
||||
}
|
||||
|
||||
protected hassReconnected() {
|
||||
super.hassReconnected();
|
||||
this._loadBackendTranslations();
|
||||
this._applyTranslations(this.hass!);
|
||||
}
|
||||
|
||||
protected panelUrlChanged(newPanelUrl) {
|
||||
super.panelUrlChanged(newPanelUrl);
|
||||
this._loadTranslationFragment(newPanelUrl);
|
||||
// this may be triggered before hassConnected
|
||||
this._loadFragmentTranslations(
|
||||
this.hass ? this.hass.language : getLocalLanguage(),
|
||||
newPanelUrl
|
||||
);
|
||||
}
|
||||
|
||||
private async _loadBackendTranslations() {
|
||||
const hass = this.hass;
|
||||
if (!hass || !hass.language) {
|
||||
private _selectLanguage(language: string, saveToBackend: boolean) {
|
||||
if (!this.hass) {
|
||||
// should not happen, do it to avoid use this.hass!
|
||||
return;
|
||||
}
|
||||
|
||||
const language = hass.selectedLanguage || hass.language;
|
||||
// update selectedLanguage so that it can be saved to local storage
|
||||
this._updateHass({ language, selectedLanguage: language });
|
||||
storeState(this.hass);
|
||||
if (saveToBackend) {
|
||||
saveFrontendUserData(this.hass, "language", { language });
|
||||
}
|
||||
|
||||
const { resources } = await hass.callWS({
|
||||
type: "frontend/get_translations",
|
||||
language,
|
||||
});
|
||||
this._applyTranslations(this.hass);
|
||||
}
|
||||
|
||||
// If we've switched selected languages just ignore this response
|
||||
if ((hass.selectedLanguage || hass.language) !== language) {
|
||||
private _applyTranslations(hass: HomeAssistant) {
|
||||
this.style.direction = computeRTL(hass) ? "rtl" : "ltr";
|
||||
this._loadCoreTranslations(hass.language);
|
||||
this._loadHassTranslations(hass.language);
|
||||
this._loadFragmentTranslations(hass.language, hass.panelUrl);
|
||||
}
|
||||
|
||||
private async _loadHassTranslations(language: string) {
|
||||
const resources = await getHassTranslations(this.hass!, language);
|
||||
|
||||
// Ignore the repsonse if user switched languages before we got response
|
||||
if (this.hass!.language !== language) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._updateResources(language, resources);
|
||||
}
|
||||
|
||||
private _loadTranslationFragment(panelUrl) {
|
||||
private async _loadFragmentTranslations(
|
||||
language: string,
|
||||
panelUrl: string
|
||||
) {
|
||||
if (translationMetadata.fragments.includes(panelUrl)) {
|
||||
this._loadResources(panelUrl);
|
||||
const result = await getTranslation(panelUrl, language);
|
||||
this._updateResources(result.language, result.data);
|
||||
}
|
||||
}
|
||||
|
||||
private async _loadResources(fragment?) {
|
||||
const result = await getTranslation(fragment);
|
||||
private async _loadCoreTranslations(language: string) {
|
||||
const result = await getTranslation(null, language);
|
||||
this._updateResources(result.language, result.data);
|
||||
}
|
||||
|
||||
private _updateResources(language, data) {
|
||||
private _updateResources(language: string, data: any) {
|
||||
// Update the language in hass, and update the resources with the newly
|
||||
// loaded resources. This merges the new data on top of the old data for
|
||||
// this language, so that the full translation set can be loaded across
|
||||
@ -88,14 +119,4 @@ export default (superClass: Constructor<LitElement & HassBaseEl>) =>
|
||||
}
|
||||
this._updateHass(changes);
|
||||
}
|
||||
|
||||
private _selectLanguage(event) {
|
||||
const language: string = event.detail.language;
|
||||
this._updateHass({ language, selectedLanguage: language });
|
||||
this.style.direction = computeRTL(this.hass!) ? "rtl" : "ltr";
|
||||
storeState(this.hass);
|
||||
this._loadResources();
|
||||
this._loadBackendTranslations();
|
||||
this._loadTranslationFragment(this.hass!.panelUrl);
|
||||
}
|
||||
};
|
||||
|
@ -4,7 +4,7 @@ import {
|
||||
PropertyDeclarations,
|
||||
PropertyValues,
|
||||
} from "lit-element";
|
||||
import { getActiveTranslation } from "../util/hass-translation";
|
||||
import { getLocalLanguage } from "../util/hass-translation";
|
||||
import { localizeLiteBaseMixin } from "./localize-lite-base-mixin";
|
||||
import { computeLocalize, LocalizeFunc } from "../common/translations/localize";
|
||||
|
||||
@ -35,7 +35,8 @@ export const litLocalizeLiteMixin = <T extends LitElement>(
|
||||
super();
|
||||
// This will prevent undefined errors if called before connected to DOM.
|
||||
this.localize = empty;
|
||||
this.language = getActiveTranslation();
|
||||
// Use browser language setup before login.
|
||||
this.language = getLocalLanguage();
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
|
@ -35,7 +35,10 @@ export const localizeLiteBaseMixin = (superClass) =>
|
||||
}
|
||||
|
||||
private async _updateResources() {
|
||||
const { language, data } = await getTranslation(this.translationFragment);
|
||||
const { language, data } = await getTranslation(
|
||||
this.translationFragment,
|
||||
this.language
|
||||
);
|
||||
this.resources = {
|
||||
[language]: data,
|
||||
};
|
||||
|
@ -2,7 +2,7 @@
|
||||
* Lite mixin to add localization without depending on the Hass object.
|
||||
*/
|
||||
import { dedupingMixin } from "@polymer/polymer/lib/utils/mixin";
|
||||
import { getActiveTranslation } from "../util/hass-translation";
|
||||
import { getLocalLanguage } from "../util/hass-translation";
|
||||
import { localizeLiteBaseMixin } from "./localize-lite-base-mixin";
|
||||
import { computeLocalize } from "../common/translations/localize";
|
||||
|
||||
@ -16,7 +16,8 @@ export const localizeLiteMixin = dedupingMixin(
|
||||
return {
|
||||
language: {
|
||||
type: String,
|
||||
value: getActiveTranslation(),
|
||||
// Use browser language setup before login.
|
||||
value: getLocalLanguage(),
|
||||
},
|
||||
resources: Object,
|
||||
// The fragment to load.
|
||||
|
@ -117,8 +117,14 @@ export interface HomeAssistant {
|
||||
panelUrl: string;
|
||||
|
||||
// i18n
|
||||
// current effective language, in that order:
|
||||
// - backend saved user selected lanugage
|
||||
// - language in local appstorage
|
||||
// - browser language
|
||||
// - english (en)
|
||||
language: string;
|
||||
selectedLanguage?: string;
|
||||
// local stored language, keep that name for backward compability
|
||||
selectedLanguage: string;
|
||||
resources: Resources;
|
||||
localize: LocalizeFunc;
|
||||
translationMetadata: {
|
||||
|
@ -1,11 +1,12 @@
|
||||
import { HomeAssistant } from "../types";
|
||||
|
||||
const STORED_STATE = ["dockedSidebar", "selectedTheme", "selectedLanguage"];
|
||||
const STORAGE = window.localStorage || {};
|
||||
|
||||
export function storeState(hass) {
|
||||
export function storeState(hass: HomeAssistant) {
|
||||
try {
|
||||
for (var i = 0; i < STORED_STATE.length; i++) {
|
||||
var key = STORED_STATE[i];
|
||||
var value = hass[key];
|
||||
for (const key of STORED_STATE) {
|
||||
const value = hass[key];
|
||||
STORAGE[key] = JSON.stringify(value === undefined ? null : value);
|
||||
}
|
||||
} catch (err) {
|
||||
@ -14,10 +15,9 @@ export function storeState(hass) {
|
||||
}
|
||||
|
||||
export function getState() {
|
||||
var state = {};
|
||||
const state = {};
|
||||
|
||||
for (var i = 0; i < STORED_STATE.length; i++) {
|
||||
var key = STORED_STATE[i];
|
||||
for (const key of STORED_STATE) {
|
||||
if (key in STORAGE) {
|
||||
state[key] = JSON.parse(STORAGE[key]);
|
||||
}
|
||||
@ -28,5 +28,7 @@ export function getState() {
|
||||
|
||||
export function clearState() {
|
||||
// STORAGE is an object if localStorage not available.
|
||||
if (STORAGE.clear) STORAGE.clear();
|
||||
if (STORAGE.clear) {
|
||||
STORAGE.clear();
|
||||
}
|
||||
}
|
@ -1,58 +1,89 @@
|
||||
import { translationMetadata } from "../resources/translations-metadata";
|
||||
import { fetchFrontendUserData } from "../data/frontend";
|
||||
import { HomeAssistant } from "../types";
|
||||
|
||||
export function getActiveTranslation() {
|
||||
// Perform case-insenstive comparison since browser isn't required to
|
||||
// report languages with specific cases.
|
||||
const lookup = {};
|
||||
/* eslint-disable no-undef */
|
||||
Object.keys(translationMetadata.translations).forEach((tr) => {
|
||||
lookup[tr.toLowerCase()] = tr;
|
||||
});
|
||||
const STORAGE = window.localStorage || {};
|
||||
|
||||
// Search for a matching translation from most specific to general
|
||||
function languageGetTranslation(language) {
|
||||
const lang = language.toLowerCase();
|
||||
// Chinese locales need map to Simplified or Traditional Chinese
|
||||
const LOCALE_LOOKUP = {
|
||||
"zh-cn": "zh-Hans",
|
||||
"zh-sg": "zh-Hans",
|
||||
"zh-my": "zh-Hans",
|
||||
"zh-tw": "zh-Hant",
|
||||
"zh-hk": "zh-Hant",
|
||||
"zh-mo": "zh-Hant",
|
||||
zh: "zh-Hant", // all other Chinese locales map to Traditional Chinese
|
||||
};
|
||||
|
||||
if (lookup[lang]) {
|
||||
return lookup[lang];
|
||||
}
|
||||
if (lang.split("-")[0] === "zh") {
|
||||
return lang === "zh-cn" || lang === "zh-sg" ? "zh-Hans" : "zh-Hant";
|
||||
}
|
||||
return null;
|
||||
/**
|
||||
* Search for a matching translation from most specific to general
|
||||
*/
|
||||
function findAvailableLanguage(language: string) {
|
||||
// In most case, the language has the same format with our translation meta data
|
||||
if (language in translationMetadata.translations) {
|
||||
return language;
|
||||
}
|
||||
|
||||
let translation = null;
|
||||
let selectedLanguage;
|
||||
if (window.localStorage.selectedLanguage) {
|
||||
// Perform case-insenstive comparison since browser isn't required to
|
||||
// report languages with specific cases.
|
||||
const langLower = language.toLowerCase();
|
||||
|
||||
if (langLower in LOCALE_LOOKUP) {
|
||||
return LOCALE_LOOKUP[langLower];
|
||||
}
|
||||
|
||||
for (const lang in Object.keys(translationMetadata.translations)) {
|
||||
if (lang.toLowerCase() === langLower) {
|
||||
return lang;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user selected language from backend
|
||||
*/
|
||||
export async function getUserLanguage(hass: HomeAssistant) {
|
||||
const { language } = await fetchFrontendUserData(hass, "language");
|
||||
if (language) {
|
||||
const availableLanguage = findAvailableLanguage(language);
|
||||
if (availableLanguage) {
|
||||
return availableLanguage;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get browser specific language
|
||||
*/
|
||||
export function getLocalLanguage() {
|
||||
let language = null;
|
||||
if (STORAGE.selectedLanguage) {
|
||||
try {
|
||||
selectedLanguage = JSON.parse(window.localStorage.selectedLanguage);
|
||||
language = findAvailableLanguage(JSON.parse(STORAGE.selectedLanguage));
|
||||
if (language) {
|
||||
return language;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parsing error.
|
||||
}
|
||||
}
|
||||
if (selectedLanguage) {
|
||||
translation = languageGetTranslation(selectedLanguage);
|
||||
if (translation) {
|
||||
return translation;
|
||||
}
|
||||
}
|
||||
if (navigator.languages) {
|
||||
for (const locale of navigator.languages) {
|
||||
translation = languageGetTranslation(locale);
|
||||
if (translation) {
|
||||
return translation;
|
||||
language = findAvailableLanguage(locale);
|
||||
if (language) {
|
||||
return language;
|
||||
}
|
||||
}
|
||||
}
|
||||
translation = languageGetTranslation(navigator.language);
|
||||
if (translation) {
|
||||
return translation;
|
||||
language = findAvailableLanguage(navigator.language);
|
||||
if (language) {
|
||||
return language;
|
||||
}
|
||||
if (navigator.language.includes("-")) {
|
||||
translation = languageGetTranslation(navigator.language.split("-")[0]);
|
||||
if (translation) {
|
||||
return translation;
|
||||
if (navigator.language && navigator.language.includes("-")) {
|
||||
language = findAvailableLanguage(navigator.language.split("-")[0]);
|
||||
if (language) {
|
||||
return language;
|
||||
}
|
||||
}
|
||||
|
||||
@ -64,43 +95,50 @@ export function getActiveTranslation() {
|
||||
// when DOM is created in Polymer. Even a cache lookup creates noticeable latency.
|
||||
const translations = {};
|
||||
|
||||
export function getTranslation(fragment?, translationInput?) {
|
||||
const translation = translationInput || getActiveTranslation();
|
||||
const metadata = translationMetadata.translations[translation];
|
||||
async function fetchTranslation(fingerprint) {
|
||||
const response = await fetch(`/static/translations/${fingerprint}`, {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Fail to fetch translation ${fingerprint}: HTTP response status is ${
|
||||
response.status
|
||||
}`
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getTranslation(
|
||||
fragment: string | null,
|
||||
language: string
|
||||
) {
|
||||
const metadata = translationMetadata.translations[language];
|
||||
if (!metadata) {
|
||||
if (translationInput !== "en") {
|
||||
if (language !== "en") {
|
||||
return getTranslation(fragment, "en");
|
||||
}
|
||||
return Promise.reject(new Error("Language en not found in metadata"));
|
||||
throw new Error("Language en is not found in metadata");
|
||||
}
|
||||
const translationFingerprint =
|
||||
metadata.fingerprints[
|
||||
fragment ? `${fragment}/${translation}` : translation
|
||||
];
|
||||
const fingerprint =
|
||||
metadata.fingerprints[fragment ? `${fragment}/${language}` : language];
|
||||
|
||||
// Create a promise to fetch translation from the server
|
||||
if (!translations[translationFingerprint]) {
|
||||
translations[translationFingerprint] = fetch(
|
||||
`/static/translations/${translationFingerprint}`,
|
||||
{ credentials: "same-origin" }
|
||||
)
|
||||
.then((response) => response.json())
|
||||
.then((data) => ({
|
||||
language: translation,
|
||||
data,
|
||||
}))
|
||||
// Fetch translation from the server
|
||||
if (!translations[fingerprint]) {
|
||||
translations[fingerprint] = fetchTranslation(fingerprint)
|
||||
.then((data) => ({ language, data }))
|
||||
.catch((error) => {
|
||||
delete translations[translationFingerprint];
|
||||
if (translationInput !== "en") {
|
||||
delete translations[fingerprint];
|
||||
if (language !== "en") {
|
||||
// Couldn't load selected translation. Try a fall back to en before failing.
|
||||
return getTranslation(fragment, "en");
|
||||
}
|
||||
return Promise.reject(error);
|
||||
});
|
||||
}
|
||||
return translations[translationFingerprint];
|
||||
return translations[fingerprint];
|
||||
}
|
||||
|
||||
// Load selected translation into memory immediately so it is ready when Polymer
|
||||
// initializes.
|
||||
getTranslation();
|
||||
getTranslation(null, getLocalLanguage());
|
||||
|
Loading…
x
Reference in New Issue
Block a user