470 lines
18 KiB
TypeScript
470 lines
18 KiB
TypeScript
import { StringLike } from './StringLike';
|
|
import { Format } from './format';
|
|
import { Emitter, Unsubscribe, createNanoEvents } from 'nanoevents';
|
|
import Config from '../../config.json';
|
|
|
|
/// All string keys.
|
|
export enum I18nStringKey {
|
|
// Generic things
|
|
kGeneric_CollabVM = 'kGeneric_CollabVM',
|
|
kGeneric_Yes = 'kGeneric_Yes',
|
|
kGeneric_No = 'kGeneric_No',
|
|
kGeneric_Ok = 'kGeneric_Ok',
|
|
kGeneric_Cancel = 'kGeneric_Cancel',
|
|
kGeneric_Send = 'kGeneric_Send',
|
|
kGeneric_Understood = 'kGeneric_Understood',
|
|
kGeneric_Username = 'kGeneric_Username',
|
|
kGeneric_Password = 'kGeneric_Password',
|
|
kGeneric_Login = 'kGeneric_Login',
|
|
kGeneric_Register = 'kGeneric_Register',
|
|
kGeneric_EMail = 'kGeneric_EMail',
|
|
kGeneric_DateOfBirth = 'kGeneric_DateOfBirth',
|
|
kGeneric_VerificationCode = 'kGeneric_VerificationCode',
|
|
kGeneric_Verify = 'kGeneric_Verify',
|
|
kGeneric_Update = 'kGeneric_Update',
|
|
kGeneric_Logout = 'kGeneric_Logout',
|
|
|
|
kWelcomeModal_Header = 'kWelcomeModal_Header',
|
|
kWelcomeModal_Body = 'kWelcomeModal_Body',
|
|
|
|
kSiteButtons_Home = 'kSiteButtons_Home',
|
|
kSiteButtons_FAQ = 'kSiteButtons_FAQ',
|
|
kSiteButtons_Rules = 'kSiteButtons_Rules',
|
|
kSiteButtons_DarkMode = 'kSiteButtons_DarkMode',
|
|
kSiteButtons_LightMode = 'kSiteButtons_LightMode',
|
|
kSiteButtons_Languages = 'kSiteButtons_Languages',
|
|
|
|
kVM_UsersOnlineText = 'kVM_UsersOnlineText',
|
|
|
|
kVM_TurnTimeTimer = 'kVM_TurnTimeTimer',
|
|
kVM_WaitingTurnTimer = 'kVM_WaitingTurnTimer',
|
|
kVM_VoteCooldownTimer = 'kVM_VoteCooldownTimer',
|
|
|
|
kVM_VoteForResetTitle = 'kVM_VoteForResetTitle',
|
|
kVM_VoteForResetTimer = 'kVM_VoteForResetTimer',
|
|
|
|
kVMButtons_TakeTurn = 'kVMButtons_TakeTurn',
|
|
kVMButtons_EndTurn = 'kVMButtons_EndTurn',
|
|
kVMButtons_ChangeUsername = 'kVMButtons_ChangeUsername',
|
|
kVMButtons_Keyboard = 'kVMButtons_Keyboard',
|
|
KVMButtons_CtrlAltDel = 'KVMButtons_CtrlAltDel',
|
|
|
|
kVMButtons_VoteForReset = 'kVMButtons_VoteForReset',
|
|
kVMButtons_Screenshot = 'kVMButtons_Screenshot',
|
|
|
|
// Admin VM buttons
|
|
kQEMUMonitor = 'kQEMUMonitor',
|
|
kAdminVMButtons_PassVote = 'kAdminVMButtons_PassVote',
|
|
kAdminVMButtons_CancelVote = 'kAdminVMButtons_CancelVote',
|
|
|
|
kAdminVMButtons_Restore = 'kAdminVMButtons_Restore',
|
|
kAdminVMButtons_Reboot = 'kAdminVMButtons_Reboot',
|
|
kAdminVMButtons_ClearTurnQueue = 'kAdminVMButtons_ClearTurnQueue',
|
|
kAdminVMButtons_BypassTurn = 'kAdminVMButtons_BypassTurn',
|
|
kAdminVMButtons_IndefiniteTurn = 'kAdminVMButtons_IndefiniteTurn',
|
|
kAdminVMButtons_GhostTurnOn = 'kAdminVMButtons_GhostTurnOn',
|
|
kAdminVMButtons_GhostTurnOff = 'kAdminVMButtons_GhostTurnOff',
|
|
|
|
kAdminVMButtons_Ban = 'kAdminVMButtons_Ban',
|
|
kAdminVMButtons_Kick = 'kAdminVMButtons_Kick',
|
|
kAdminVMButtons_TempMute = 'kAdminVMButtons_TempMute',
|
|
kAdminVMButtons_IndefMute = 'kAdminVMButtons_IndefMute',
|
|
kAdminVMButtons_Unmute = 'kAdminVMButtons_Unmute',
|
|
kAdminVMButtons_GetIP = 'kAdminVMButtons_GetIP',
|
|
|
|
// prompts
|
|
kVMPrompts_AdminChangeUsernamePrompt = 'kVMPrompts_AdminChangeUsernamePrompt',
|
|
kVMPrompts_AdminRestoreVMPrompt = 'kVMPrompts_AdminRestoreVMPrompt',
|
|
kVMPrompts_EnterNewUsernamePrompt = 'kVMPrompts_EnterNewUsernamePrompt',
|
|
|
|
// error messages
|
|
kError_UnexpectedDisconnection = 'kError_UnexpectedDisconnection',
|
|
|
|
kError_UsernameTaken = 'kError_UsernameTaken',
|
|
kError_UsernameInvalid = 'kError_UsernameInvalid',
|
|
kError_UsernameBlacklisted = 'kError_UsernameBlacklisted',
|
|
kError_IncorrectPassword = 'kError_IncorrectPassword',
|
|
|
|
// Auth
|
|
kAccountModal_Verify = 'kAccountModal_Verify',
|
|
kAccountModal_AccountSettings = 'kAccountModal_AccountSettings',
|
|
kAccountModal_ResetPassword = 'kAccountModal_ResetPassword',
|
|
|
|
kAccountModal_NewPassword = 'kAccountModal_NewPassword',
|
|
kAccountModal_ConfirmNewPassword = 'kAccountModal_ConfirmNewPassword',
|
|
kAccountModal_CurrentPassword = 'kAccountModal_CurrentPassword',
|
|
kAccountModal_ConfirmPassword = 'kAccountModal_ConfirmPassword',
|
|
kAccountModal_HideFlag = 'kAccountModal_HideFlag',
|
|
|
|
kAccountModal_VerifyText = 'kAccountModal_VerifyText',
|
|
kAccountModal_VerifyPasswordResetText = 'kAccountModal_VerifyPasswordResetText',
|
|
kAccountModal_PasswordResetSuccess = 'kAccountModal_PasswordResetSuccess',
|
|
kMissingCaptcha = 'kMissingCaptcha',
|
|
kPasswordsMustMatch = 'kPasswordsMustMatch',
|
|
|
|
kNotLoggedIn = 'kNotLoggedIn',
|
|
}
|
|
|
|
export interface I18nEvents {
|
|
// Called when the language is changed
|
|
languageChanged: (lang: string) => void;
|
|
}
|
|
|
|
// This models the JSON structure.
|
|
export type Language = {
|
|
languageName: string;
|
|
translatedLanguageName: string;
|
|
flag: string; // country flag, can be blank if not applicable. will be displayed in language dropdown
|
|
author: string;
|
|
|
|
stringKeys: {
|
|
// This is fancy typescript speak for
|
|
// "any string index returns a string",
|
|
// which is our expectation.
|
|
// See https://www.typescriptlang.org/docs/handbook/2/objects.html#index-signatures if this is confusing.
|
|
[key: string]: string;
|
|
};
|
|
};
|
|
|
|
export type LanguageMetadata = {
|
|
languageName: string;
|
|
flag: string; // country flag, can be blank if not applicable. will be displayed in language dropdown
|
|
};
|
|
|
|
// `languages.json`
|
|
export type LanguagesJson = {
|
|
// Array of language IDs to allow loading
|
|
languages: {[key: string]: LanguageMetadata};
|
|
|
|
// The default language (set if a invalid language not in the languages array is set, or no language is set)
|
|
defaultLanguage: string;
|
|
};
|
|
|
|
// ID for fallback language
|
|
const fallbackId = '!!fallback';
|
|
|
|
// This language is provided in the webapp itself just in case language stuff fails
|
|
import fallbackLanguage from './fallbackLanguage.js';
|
|
|
|
interface StringKeyMap {
|
|
[k: string]: I18nStringKey;
|
|
}
|
|
|
|
/// our fancy internationalization helper.
|
|
export class I18n {
|
|
// The language data itself
|
|
private langs : Map<string, LanguageMetadata> = new Map<string, Language>();
|
|
private lang: Language = fallbackLanguage;
|
|
private languageDropdown: HTMLSpanElement = document.getElementById('languageDropdown') as HTMLSpanElement;
|
|
private emitter: Emitter<I18nEvents> = createNanoEvents();
|
|
|
|
CurrentLanguage = () => this.langId;
|
|
|
|
// the ID of the language
|
|
private langId: string = fallbackId;
|
|
|
|
private regionNameRenderer = new Intl.DisplayNames(['en-US'], {type: 'region'});
|
|
|
|
async Init() {
|
|
// Load language list
|
|
var res = await fetch("lang/languages.json");
|
|
if (!res.ok) {
|
|
alert("Failed to load languages.json: " + res.statusText);
|
|
await this.SetLanguage(fallbackId);
|
|
this.ReplaceStaticStrings();
|
|
return;
|
|
}
|
|
var langData = await res.json() as LanguagesJson;
|
|
for (const langId in langData.languages) {
|
|
this.langs.set(langId, langData.languages[langId]);
|
|
}
|
|
this.langs.forEach((_lang, langId) => {
|
|
// Add to language dropdown
|
|
var a = document.createElement('a');
|
|
a.classList.add('dropdown-item');
|
|
a.href = '#';
|
|
a.innerText = `${_lang.flag} ${_lang.languageName}`;
|
|
a.addEventListener('click', async e => {
|
|
e.preventDefault();
|
|
await this.SetLanguage(langId);
|
|
this.ReplaceStaticStrings();
|
|
});
|
|
this.languageDropdown.appendChild(a);
|
|
});
|
|
let lang = null;
|
|
let lsLang = window.localStorage.getItem('i18n-lang');
|
|
var browserLang = navigator.language.toLowerCase();
|
|
// If the language is set in localstorage, use that
|
|
if (lsLang !== null && this.langs.has(lsLang)) lang = lsLang;
|
|
// If the browser language is in the list, use that
|
|
else if (this.langs.has(browserLang)) lang = browserLang;
|
|
else {
|
|
// If the exact browser language isn't in the list, try to find a language with the same prefix
|
|
for (let langId in langData.languages) {
|
|
if (langId.split('-')[0] === browserLang.split('-')[0]) {
|
|
lang = langId;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// If all else fails, use the default language
|
|
if (lang === null) lang = langData.defaultLanguage;
|
|
await this.SetLanguage(lang);
|
|
this.ReplaceStaticStrings();
|
|
}
|
|
|
|
getCountryName(code: string) : string {
|
|
return this.regionNameRenderer.of(code) || code;
|
|
}
|
|
|
|
private async SetLanguage(id: string) {
|
|
let lastId = this.langId;
|
|
this.langId = id;
|
|
|
|
let lang;
|
|
if (id === fallbackId) lang = fallbackLanguage;
|
|
else {
|
|
let path = `./lang/${id}.json`;
|
|
let res = await fetch(path);
|
|
if (!res.ok) {
|
|
console.error(`Failed to load lang/${id}.json: ${res.statusText}`);
|
|
await this.SetLanguage(fallbackId);
|
|
return;
|
|
}
|
|
lang = await res.json() as Language;
|
|
}
|
|
|
|
this.lang = lang;
|
|
|
|
if (this.langId != lastId) {
|
|
// Replace static strings
|
|
this.ReplaceStaticStrings();
|
|
|
|
// Update region name renderer target language
|
|
this.regionNameRenderer = new Intl.DisplayNames([this.langId], {type: 'region'});
|
|
};
|
|
|
|
// Set the language ID localstorage entry
|
|
if (this.langId !== fallbackId) {
|
|
window.localStorage.setItem('i18n-lang', this.langId);
|
|
}
|
|
|
|
this.emitter.emit('languageChanged', this.langId);
|
|
console.log('i18n initalized for', id, 'sucessfully!');
|
|
}
|
|
|
|
// Replaces static strings that we don't recompute
|
|
private ReplaceStaticStrings() {
|
|
const kDomIdtoStringMap: StringKeyMap = {
|
|
siteNameText: I18nStringKey.kGeneric_CollabVM,
|
|
homeBtnText: I18nStringKey.kSiteButtons_Home,
|
|
faqBtnText: I18nStringKey.kSiteButtons_FAQ,
|
|
rulesBtnText: I18nStringKey.kSiteButtons_Rules,
|
|
accountLoginButton: I18nStringKey.kGeneric_Login,
|
|
accountRegisterButton: I18nStringKey.kGeneric_Register,
|
|
accountSettingsButton: I18nStringKey.kAccountModal_AccountSettings,
|
|
accountLogoutButton: I18nStringKey.kGeneric_Logout,
|
|
languageDropdownText: I18nStringKey.kSiteButtons_Languages,
|
|
|
|
welcomeModalHeader: I18nStringKey.kWelcomeModal_Header,
|
|
welcomeModalBody: I18nStringKey.kWelcomeModal_Body,
|
|
welcomeModalDismiss: I18nStringKey.kGeneric_Understood,
|
|
|
|
usersOnlineText: I18nStringKey.kVM_UsersOnlineText,
|
|
|
|
voteResetHeaderText: I18nStringKey.kVM_VoteForResetTitle,
|
|
voteYesBtnText: I18nStringKey.kGeneric_Yes,
|
|
voteNoBtnText: I18nStringKey.kGeneric_No,
|
|
|
|
changeUsernameBtnText: I18nStringKey.kVMButtons_ChangeUsername,
|
|
oskBtnText: I18nStringKey.kVMButtons_Keyboard,
|
|
ctrlAltDelBtnText: I18nStringKey.KVMButtons_CtrlAltDel,
|
|
voteForResetBtnText: I18nStringKey.kVMButtons_VoteForReset,
|
|
screenshotBtnText: I18nStringKey.kVMButtons_Screenshot,
|
|
|
|
// admin stuff
|
|
badPasswordAlertText: I18nStringKey.kError_IncorrectPassword,
|
|
loginModalPasswordText: I18nStringKey.kGeneric_Password,
|
|
loginButton: I18nStringKey.kGeneric_Login,
|
|
passVoteBtnText: I18nStringKey.kAdminVMButtons_PassVote,
|
|
cancelVoteBtnText: I18nStringKey.kAdminVMButtons_CancelVote,
|
|
endTurnBtnText: I18nStringKey.kVMButtons_EndTurn,
|
|
qemuMonitorBtnText: I18nStringKey.kQEMUMonitor,
|
|
qemuModalHeader: I18nStringKey.kQEMUMonitor,
|
|
qemuMonitorSendBtn: I18nStringKey.kGeneric_Send,
|
|
|
|
restoreBtnText: I18nStringKey.kAdminVMButtons_Restore,
|
|
rebootBtnText: I18nStringKey.kAdminVMButtons_Reboot,
|
|
clearQueueBtnText: I18nStringKey.kAdminVMButtons_ClearTurnQueue,
|
|
bypassTurnBtnText: I18nStringKey.kAdminVMButtons_BypassTurn,
|
|
indefTurnBtnText: I18nStringKey.kAdminVMButtons_IndefiniteTurn,
|
|
ghostTurnBtnText: I18nStringKey.kAdminVMButtons_GhostTurnOff,
|
|
|
|
// Account modal
|
|
accountLoginUsernameLabel: I18nStringKey.kGeneric_Username,
|
|
accountLoginPasswordLabel: I18nStringKey.kGeneric_Password,
|
|
accountModalLoginBtn: I18nStringKey.kGeneric_Login,
|
|
accountForgotPasswordButton: I18nStringKey.kAccountModal_ResetPassword,
|
|
accountRegisterEmailLabel: I18nStringKey.kGeneric_EMail,
|
|
accountRegisterUsernameLabel: I18nStringKey.kGeneric_Username,
|
|
accountRegisterPasswordLabel: I18nStringKey.kGeneric_Password,
|
|
accountRegisterConfirmPasswordLabel: I18nStringKey.kAccountModal_ConfirmPassword,
|
|
accountRegisterDateOfBirthLabel: I18nStringKey.kGeneric_DateOfBirth,
|
|
accountModalRegisterBtn: I18nStringKey.kGeneric_Register,
|
|
accountVerifyEmailCodeLabel: I18nStringKey.kGeneric_VerificationCode,
|
|
accountVerifyEmailPasswordLabel: I18nStringKey.kGeneric_Password,
|
|
accountModalVerifyEmailBtn: I18nStringKey.kGeneric_Verify,
|
|
accountSettingsEmailLabel: I18nStringKey.kGeneric_EMail,
|
|
accountSettingsUsernameLabel: I18nStringKey.kGeneric_Username,
|
|
accountSettingsNewPasswordLabel: I18nStringKey.kAccountModal_NewPassword,
|
|
accountSettingsConfirmNewPasswordLabel: I18nStringKey.kAccountModal_ConfirmNewPassword,
|
|
accountSettingsCurrentPasswordLabel: I18nStringKey.kAccountModal_CurrentPassword,
|
|
hideFlagCheckboxLabel: I18nStringKey.kAccountModal_HideFlag,
|
|
updateAccountSettingsBtn: I18nStringKey.kGeneric_Update,
|
|
accountResetPasswordEmailLabel: I18nStringKey.kGeneric_EMail,
|
|
accountResetPasswordUsernameLabel: I18nStringKey.kGeneric_Username,
|
|
accountResetPasswordBtn: I18nStringKey.kAccountModal_ResetPassword,
|
|
accountResetPasswordCodeLabel: I18nStringKey.kGeneric_VerificationCode,
|
|
accountResetPasswordNewPasswordLabel: I18nStringKey.kAccountModal_NewPassword,
|
|
accountResetPasswordConfirmNewPasswordLabel: I18nStringKey.kAccountModal_ConfirmNewPassword,
|
|
accountResetPasswordVerifyBtn: I18nStringKey.kAccountModal_ResetPassword,
|
|
};
|
|
|
|
const kDomAttributeToStringMap = {
|
|
adminPassword: {
|
|
placeholder: I18nStringKey.kGeneric_Password,
|
|
},
|
|
accountLoginUsername: {
|
|
placeholder: I18nStringKey.kGeneric_Username,
|
|
},
|
|
accountLoginPassword: {
|
|
placeholder: I18nStringKey.kGeneric_Password,
|
|
},
|
|
accountRegisterEmail: {
|
|
placeholder: I18nStringKey.kGeneric_EMail,
|
|
},
|
|
accountRegisterUsername: {
|
|
placeholder: I18nStringKey.kGeneric_Username,
|
|
},
|
|
accountRegisterPassword: {
|
|
placeholder: I18nStringKey.kGeneric_Password,
|
|
},
|
|
accountRegisterConfirmPassword: {
|
|
placeholder: I18nStringKey.kAccountModal_ConfirmPassword,
|
|
},
|
|
accountVerifyEmailCode: {
|
|
placeholder: I18nStringKey.kGeneric_VerificationCode,
|
|
},
|
|
accountVerifyEmailPassword: {
|
|
placeholder: I18nStringKey.kGeneric_Password,
|
|
},
|
|
accountSettingsEmail: {
|
|
placeholder: I18nStringKey.kGeneric_EMail,
|
|
},
|
|
accountSettingsUsername: {
|
|
placeholder: I18nStringKey.kGeneric_Username,
|
|
},
|
|
accountSettingsNewPassword: {
|
|
placeholder: I18nStringKey.kAccountModal_NewPassword,
|
|
},
|
|
accountSettingsConfirmNewPassword: {
|
|
placeholder: I18nStringKey.kAccountModal_ConfirmNewPassword,
|
|
},
|
|
accountSettingsCurrentPassword: {
|
|
placeholder: I18nStringKey.kAccountModal_CurrentPassword,
|
|
},
|
|
accountResetPasswordEmail: {
|
|
placeholder: I18nStringKey.kGeneric_EMail,
|
|
},
|
|
accountResetPasswordUsername: {
|
|
placeholder: I18nStringKey.kGeneric_Username,
|
|
},
|
|
accountResetPasswordCode: {
|
|
placeholder: I18nStringKey.kGeneric_VerificationCode,
|
|
},
|
|
accountResetPasswordNewPassword: {
|
|
placeholder: I18nStringKey.kAccountModal_NewPassword,
|
|
},
|
|
accountResetPasswordConfirmNewPassword: {
|
|
placeholder: I18nStringKey.kAccountModal_ConfirmNewPassword,
|
|
},
|
|
};
|
|
|
|
const kDomClassToStringMap: StringKeyMap = {
|
|
"mod-end-turn-btn": I18nStringKey.kVMButtons_EndTurn,
|
|
"mod-ban-btn": I18nStringKey.kAdminVMButtons_Ban,
|
|
"mod-kick-btn": I18nStringKey.kAdminVMButtons_Kick,
|
|
"mod-change-username-btn": I18nStringKey.kVMButtons_ChangeUsername,
|
|
"mod-temp-mute-btn": I18nStringKey.kAdminVMButtons_TempMute,
|
|
"mod-indef-mute-btn": I18nStringKey.kAdminVMButtons_IndefMute,
|
|
"mod-unmute-btn": I18nStringKey.kAdminVMButtons_Unmute,
|
|
"mod-get-ip-btn": I18nStringKey.kAdminVMButtons_GetIP,
|
|
}
|
|
|
|
for (let domId of Object.keys(kDomIdtoStringMap)) {
|
|
let element = document.getElementById(domId);
|
|
if (element == null) {
|
|
alert(`Error: Could not find element with ID ${domId} in the DOM! Please tell a site admin this happened.`);
|
|
return;
|
|
}
|
|
|
|
// Do the magic.
|
|
// N.B: For now, we assume all strings in this map are not formatted.
|
|
// If this assumption changes, then we should just use GetString() again
|
|
// and maybe include arguments, but for now this is okay
|
|
element.innerHTML = this.GetStringRaw(kDomIdtoStringMap[domId]);
|
|
}
|
|
|
|
for (let domId of Object.keys(kDomAttributeToStringMap)) {
|
|
let element = document.getElementById(domId);
|
|
if (element == null) {
|
|
alert(`Error: Could not find element with ID ${domId} in the DOM! Please tell a site admin this happened.`);
|
|
return;
|
|
}
|
|
|
|
// TODO: Figure out if we can get rid of this ts-ignore
|
|
// @ts-ignore
|
|
let attributes = kDomAttributeToStringMap[domId];
|
|
for (let attr of Object.keys(attributes)) {
|
|
element.setAttribute(attr, this.GetStringRaw(attributes[attr] as I18nStringKey));
|
|
}
|
|
}
|
|
|
|
for (let domClass of Object.keys(kDomClassToStringMap)) {
|
|
let elements = document.getElementsByClassName(domClass);
|
|
for (let element of elements) {
|
|
element.innerHTML = this.GetStringRaw(kDomClassToStringMap[domClass]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Returns a (raw, unformatted) string. Currently only used if we don't need formatting.
|
|
GetStringRaw(key: I18nStringKey): string {
|
|
if (key === I18nStringKey.kGeneric_CollabVM && Config.SiteNameOverride) return Config.SiteNameOverride;
|
|
if (key === I18nStringKey.kWelcomeModal_Header && Config.WelcomeModalTitleOverride) return Config.WelcomeModalTitleOverride;
|
|
if (key === I18nStringKey.kWelcomeModal_Body && Config.WelcomeModalBodyOverride) return Config.WelcomeModalBodyOverride;
|
|
let val = this.lang.stringKeys[key];
|
|
|
|
// Look up the fallback language by default if the language doesn't
|
|
// have that string key yet; if the fallback doesn't have it either,
|
|
// then just return the string key and a bit of a notice things have gone wrong
|
|
if (val == undefined) {
|
|
let fallback = fallbackLanguage.stringKeys[key];
|
|
if (fallback !== undefined) val = fallback;
|
|
else return `${key} (ERROR LOOKING UP TRANSLATION!!!)`;
|
|
}
|
|
|
|
return val;
|
|
}
|
|
|
|
// Returns a formatted localized string.
|
|
GetString(key: I18nStringKey, ...replacements: StringLike[]): string {
|
|
return Format(this.GetStringRaw(key), ...replacements);
|
|
}
|
|
|
|
on<e extends keyof I18nEvents>(event: e, cb: I18nEvents[e]): Unsubscribe {
|
|
return this.emitter.on(event, cb);
|
|
}
|
|
}
|
|
|
|
export let TheI18n = new I18n(); |