/* * https://github.com/morethanwords/tweb * Copyright (C) 2019-2021 Eduard Kuzmenko * https://github.com/morethanwords/tweb/blob/master/LICENSE */ import type lang from '../lang'; import type langSign from '../langSign'; import type {State} from '../config/state'; import DEBUG, {MOUNT_CLASS_TO} from '../config/debug'; import {HelpCountriesList, HelpCountry, LangPackDifference, LangPackString} from '../layer'; import stateStorage from './stateStorage'; import App from '../config/app'; import rootScope from './rootScope'; import {IS_MOBILE} from '../environment/userAgent'; import deepEqual from '../helpers/object/deepEqual'; import safeAssign from '../helpers/object/safeAssign'; import capitalizeFirstLetter from '../helpers/string/capitalizeFirstLetter'; import matchUrlProtocol from './richTextProcessor/matchUrlProtocol'; import wrapUrl from './richTextProcessor/wrapUrl'; import {setDirection} from '../helpers/dom/setInnerHTML'; export const langPack: {[actionType: string]: LangPackKey} = { 'messageActionChatCreate': 'ActionCreateGroup', 'messageActionChatCreateYou': 'ActionYouCreateGroup', 'messageActionChatEditTitle': 'ActionChangedTitle', 'messageActionChatEditPhoto': 'ActionChangedPhoto', 'messageActionChatEditVideo': 'ActionChangedVideo', 'messageActionChatDeletePhoto': 'ActionRemovedPhoto', 'messageActionChatReturn': 'ActionAddUserSelf', 'messageActionChatReturnYou': 'ActionAddUserSelfYou', 'messageActionChatJoined': 'ActionAddUserSelfMega', 'messageActionChatJoinedYou': 'ChannelMegaJoined', 'messageActionChatAddUser': 'ActionAddUser', 'messageActionChatAddUsers': 'ActionAddUser', 'messageActionChatLeave': 'ActionLeftUser', 'messageActionChatLeaveYou': 'YouLeft', 'messageActionChatDeleteUser': 'ActionKickUser', 'messageActionChatJoinedByLink': 'ActionInviteUser', 'messageActionPinMessage': 'Chat.Service.Group.UpdatedPinnedMessage', 'messageActionContactSignUp': 'Chat.Service.PeerJoinedTelegram', 'messageActionChannelCreate': 'ActionCreateChannel', 'messageActionChannelEditTitle': 'Chat.Service.Channel.UpdatedTitle', 'messageActionChannelEditPhoto': 'Chat.Service.Channel.UpdatedPhoto', 'messageActionChannelEditVideo': 'Chat.Service.Channel.UpdatedVideo', 'messageActionChannelDeletePhoto': 'Chat.Service.Channel.RemovedPhoto', 'messageActionHistoryClear': 'HistoryCleared', 'messageActionDiscussionStarted': 'DiscussionStarted', 'messageActionChannelMigrateFrom': 'ActionMigrateFromGroup', 'messageActionPhoneCall.video_in_ok': 'ChatList.Service.VideoCall.incoming', 'messageActionPhoneCall.video_out_ok': 'ChatList.Service.VideoCall.outgoing', 'messageActionPhoneCall.video_missed': 'ChatList.Service.VideoCall.Missed', 'messageActionPhoneCall.video_cancelled': 'ChatList.Service.VideoCall.Cancelled', 'messageActionPhoneCall.in_ok': 'ChatList.Service.Call.incoming', 'messageActionPhoneCall.out_ok': 'ChatList.Service.Call.outgoing', 'messageActionPhoneCall.missed': 'ChatList.Service.Call.Missed', 'messageActionPhoneCall.cancelled': 'ChatList.Service.Call.Cancelled', 'messageActionGroupCall.started': 'Chat.Service.VoiceChatStarted.Channel', 'messageActionGroupCall.started_by': 'Chat.Service.VoiceChatStarted', 'messageActionGroupCall.started_byYou': 'Chat.Service.VoiceChatStartedYou', 'messageActionGroupCall.ended': 'Chat.Service.VoiceChatFinished.Channel', 'messageActionGroupCall.ended_by': 'Chat.Service.VoiceChatFinished', 'messageActionGroupCall.ended_byYou': 'Chat.Service.VoiceChatFinishedYou', 'messageActionBotAllowed': 'Chat.Service.BotPermissionAllowed' }; export type LangPackKey = /* string | */keyof typeof lang | keyof typeof langSign; export type FormatterArgument = string | number | Node | FormatterArgument[]; export type FormatterArguments = FormatterArgument[]; export const UNSUPPORTED_LANG_PACK_KEY: LangPackKey = IS_MOBILE ? 'Message.Unsupported.Mobile' : 'Message.Unsupported.Desktop'; namespace I18n { export const strings: Map = new Map(); export const countriesList: HelpCountry[] = []; let pluralRules: Intl.PluralRules; let cacheLangPackPromise: Promise; export let lastRequestedLangCode: string; export let lastRequestedNormalizedLangCode: string; export let lastAppliedLangCode: string; export let requestedServerLanguage = false; export let timeFormat: State['settings']['timeFormat']; export let isRTL = false; export function setRTL(rtl: boolean) { isRTL = rtl; } function setLangCode(langCode: string) { lastRequestedLangCode = langCode; lastRequestedNormalizedLangCode = langCode.split('-')[0]; } export function getCacheLangPack(): Promise { if(cacheLangPackPromise) return cacheLangPackPromise; return cacheLangPackPromise = Promise.all([ stateStorage.get('langPack') as Promise, polyfillPromise ]).then(([langPack]) => { if(!langPack/* || true */) { return loadLocalLangPack(); } else if(DEBUG && false) { return getLangPack(langPack.lang_code); }/* else if(langPack.appVersion !== App.langPackVersion) { return getLangPack(langPack.lang_code); } */ if(!lastRequestedLangCode) { setLangCode(langPack.lang_code); } applyLangPack(langPack); return langPack; }).finally(() => { cacheLangPackPromise = undefined; }); } function updateAmPm() { if(timeFormat === 'h12') { try { const dateTimeFormat = getDateTimeFormat({hour: 'numeric', minute: 'numeric', hour12: true}); const date = new Date(); date.setHours(0); const amText = dateTimeFormat.format(date); amPmCache.am = amText.split(/\s/)[1]; date.setHours(12); const pmText = dateTimeFormat.format(date); amPmCache.pm = pmText.split(/\s/)[1]; } catch(err) { console.error('cannot get am/pm', err); amPmCache = {am: 'AM', pm: 'PM'}; } } } export function setTimeFormat( format: State['settings']['timeFormat'], haveToUpdate = !!timeFormat && timeFormat !== format ) { timeFormat = format; updateAmPm(); if(haveToUpdate) { cachedDateTimeFormats.clear(); const elements = Array.from(document.querySelectorAll(`.i18n`)) as HTMLElement[]; elements.forEach((element) => { const instance = weakMap.get(element); if(instance instanceof IntlDateElement) { instance.update(); } }); } } export function loadLocalLangPack() { const defaultCode = App.langPackCode; setLangCode(defaultCode); return Promise.all([ import('../lang'), import('../langSign'), import('../countries') ]).then(([lang, langSign, countries]) => { const strings: LangPackString[] = []; formatLocalStrings(lang.default, strings); formatLocalStrings(langSign.default, strings); const langPack: LangPackDifference = { _: 'langPackDifference', from_version: 0, lang_code: defaultCode, strings, version: 0, local: true, countries: countries.default }; return saveLangPack(langPack); }); } export function loadLangPack(langCode: string, web?: boolean) { web = true; requestedServerLanguage = true; const managers = rootScope.managers; return Promise.all([ managers.apiManager.invokeApiCacheable('langpack.getLangPack', { lang_code: langCode, lang_pack: web ? 'web' : App.langPack }), !web && managers.apiManager.invokeApiCacheable('langpack.getLangPack', { lang_code: langCode, lang_pack: 'android' }), import('../lang'), import('../langSign'), managers.apiManager.invokeApiCacheable('help.getCountriesList', { lang_code: langCode, hash: 0 }) as Promise, polyfillPromise ]); } export function getStrings(langCode: string, strings: string[]) { return rootScope.managers.apiManager.invokeApi('langpack.getStrings', { lang_pack: App.langPack, lang_code: langCode, keys: strings }); } export function formatLocalStrings(strings: any, pushTo: LangPackString[] = []) { for(const i in strings) { // @ts-ignore const v = strings[i]; if(typeof(v) === 'string') { pushTo.push({ _: 'langPackString', key: i, value: v }); } else { pushTo.push({ _: 'langPackStringPluralized', key: i, ...v }); } } return pushTo; } export function getLangPack(langCode: string, web?: boolean) { setLangCode(langCode); return loadLangPack(langCode, web).then(([langPack1, langPack2, localLangPack1, localLangPack2, countries, _]) => { let strings: LangPackString[] = []; [localLangPack1, localLangPack2].forEach((l) => { formatLocalStrings(l.default as any, strings); }); strings = strings.concat(...[langPack1.strings, langPack2.strings].filter(Boolean)); langPack1.strings = strings; langPack1.countries = countries; return saveLangPack(langPack1); }); } export function saveLangPack(langPack: LangPackDifference) { langPack.appVersion = App.langPackVersion; return stateStorage.set({langPack}).then(() => { applyLangPack(langPack); return langPack; }); } export const polyfillPromise = (function checkIfPolyfillNeeded() { if(typeof(Intl) !== 'undefined' && typeof(Intl.PluralRules) !== 'undefined'/* && false */) { return Promise.resolve(); } else { return import('./pluralPolyfill').then((_Intl) => { (window as any).Intl = Object.assign(typeof(Intl) !== 'undefined' ? Intl : {}, _Intl.default); }); } })(); export function applyLangPack(langPack: LangPackDifference) { const currentLangCode = lastRequestedLangCode; if(langPack.lang_code !== currentLangCode) { return; } try { pluralRules = new Intl.PluralRules(lastRequestedNormalizedLangCode); } catch(err) { console.error('pluralRules error', err); pluralRules = new Intl.PluralRules(lastRequestedNormalizedLangCode.split('-', 1)[0]); } try { pluralRules = new Intl.PluralRules(langPack.lang_code); } catch(err) { console.error('pluralRules error', err); pluralRules = new Intl.PluralRules(langPack.lang_code.split('-', 1)[0]); } strings.clear(); for(const string of langPack.strings) { strings.set(string.key as LangPackKey, string); } if(langPack.countries) { countriesList.length = 0; countriesList.push(...langPack.countries.countries); langPack.countries.countries.forEach((country) => { if(country.name) { const langPackKey: any = country.default_name; strings.set(langPackKey, { _: 'langPackString', key: langPackKey, value: country.name }); } }); } if(lastAppliedLangCode !== currentLangCode) { if(lastAppliedLangCode && rootScope.myId) { rootScope.managers.appReactionsManager.resetAvailableReactions(); rootScope.managers.appUsersManager.indexMyself(); rootScope.managers.dialogsStorage.indexMyDialog(); } lastAppliedLangCode = currentLangCode; cachedDateTimeFormats.clear(); updateAmPm(); rootScope.dispatchEvent('language_change', currentLangCode); } const elements = Array.from(document.querySelectorAll(`.i18n`)) as HTMLElement[]; elements.forEach((element) => { const instance = weakMap.get(element); if(instance) { instance.update(); } }); } function pushNextArgument(out: ReturnType, args: FormatterArguments, indexHolder: {i: number}) { const arg = args[indexHolder.i++]; if(Array.isArray(arg)) { out.push(...arg as any); } else { out.push(arg); } } export function superFormatter(input: string, args?: FormatterArguments, indexHolder = {i: 0}): Exclude[] { const out: ReturnType = []; const regExp = /(\*\*|__)(.+?)\1|(\n)|(\[.+?\]\(.*?\))|un\d|%\d\$.|%./g; let lastIndex = 0; input.replace(regExp, (match, p1: any, p2: any, p3: any, p4: string, offset: number, string: string) => { // console.table({match, p1, p2, offset, string}); out.push(string.slice(lastIndex, offset)); if(p1) { // offset += p1.length; let element: HTMLElement; switch(p1) { case '**': { element = document.createElement('b'); break; } case '__': { element = document.createElement('i'); break; } } element.append(...superFormatter(p2, args, indexHolder) as any); out.push(element); } else if(p3) { out.push(document.createElement('br')); } else if(p4) { const idx = p4.lastIndexOf(']'); const text = p4.slice(1, idx); const url = p4.slice(idx + 2, p4.length - 1); let a: HTMLAnchorElement; if(url && matchUrlProtocol(url)) { a = document.createElement('a'); const wrappedUrl = wrapUrl(url); a.href = wrappedUrl.url; if(wrappedUrl.onclick) a.setAttribute('onclick', wrappedUrl.onclick + '(this)'); a.target = '_blank'; } else { a = args[indexHolder.i++] as HTMLAnchorElement; if(a instanceof DocumentFragment) { // right after wrapRichText a = a.firstChild as any; } if(typeof(a) !== 'string') { a.textContent = ''; // reset content } } const formatted = superFormatter(text, args, indexHolder) as any; if(typeof(a) === 'string') { out.push(...formatted); } else { a.append(...formatted); out.push(a); } } else if(args) { pushNextArgument(out, args, indexHolder); } lastIndex = offset + match.length; return ''; }); if(lastIndex !== input.length) { out.push(input.slice(lastIndex)); } return out; } export function format(key: LangPackKey, plain: true, args?: FormatterArguments): string; export function format(key: LangPackKey, plain?: false, args?: FormatterArguments): ReturnType; export function format(key: LangPackKey, plain = false, args?: FormatterArguments): ReturnType | string { const str = strings.get(key); let input: string; if(str) { if(str._ === 'langPackStringPluralized' && args?.length) { let v = args[0] as number | string; if(typeof(v) === 'string') v = +v.replace(/\D/g, ''); const s = pluralRules.select(v); // @ts-ignore input = str[s + '_value'] || str['other_value']; } else if(str._ === 'langPackString') { input = str.value; } else { // input = '[' + key + ']'; input = key; } } else { // input = '[' + key + ']'; input = key; } const result = superFormatter(input, args); if(plain) { // * let's try a hack now... (don't want to replace []() entity) return result.map((item) => item instanceof Node ? item.textContent : item).join(''); } else { return result; } /* if(plain) { if(args?.length) { const regExp = /un\d|%\d\$.|%./g; let i = 0; input = input.replace(regExp, (match, offset, string) => { return '' + args[i++]; }); } return input; } else { return superFormatter(input, args); } */ } export const weakMap: WeakMap> = new WeakMap(); export type IntlElementBaseOptions = { element?: HTMLElement, property?: 'innerText' | 'innerHTML' | 'placeholder' | 'textContent', }; abstract class IntlElementBase { public element: IntlElementBaseOptions['element']; public property: IntlElementBaseOptions['property']; constructor(options?: Options) { this.element = options?.element || document.createElement('span'); this.element.classList.add('i18n'); this.property = options?.property; if(options && ((options as any as IntlElementOptions).key || (options as any as IntlDateElementOptions).date)) { this.update(options); } weakMap.set(this.element, this); } abstract update(options?: Options): void; } export type IntlElementOptions = IntlElementBaseOptions & { key?: LangPackKey, args?: FormatterArguments }; export class IntlElement extends IntlElementBase { public key: IntlElementOptions['key']; public args: IntlElementOptions['args']; constructor(options: IntlElementOptions = {}) { super({...options, property: options.property ?? 'innerHTML'}); } public update(options?: IntlElementOptions) { safeAssign(this, options); if(this.property === 'innerHTML') { this.element.textContent = ''; this.element.append(...format(this.key, false, this.args) as any); } else { // @ts-ignore const v = this.element[this.property]; const formatted = format(this.key, true, this.args); // * hasOwnProperty won't work here if(v === undefined) this.element.dataset[this.property] = formatted; else (this.element as HTMLInputElement)[this.property] = formatted; } } public compareAndUpdate(options?: IntlElementOptions) { if(this.key === options.key && deepEqual(this.args, options.args)) { return; } return this.update(options); } } const cachedDateTimeFormats: Map = new Map(); export function getDateTimeFormat(options: Intl.DateTimeFormatOptions = {}) { const json = JSON.stringify(options); let dateTimeFormat = cachedDateTimeFormats.get(json); if(!dateTimeFormat) { dateTimeFormat = new Intl.DateTimeFormat(lastRequestedNormalizedLangCode + '-u-hc-' + timeFormat, options); cachedDateTimeFormats.set(json, dateTimeFormat); } return dateTimeFormat; } export let amPmCache = {am: 'AM', pm: 'PM'}; export type IntlDateElementOptions = IntlElementBaseOptions & { date?: Date, options: Intl.DateTimeFormatOptions }; export class IntlDateElement extends IntlElementBase { public date: IntlDateElementOptions['date']; public options: IntlDateElementOptions['options']; constructor(options: IntlDateElementOptions) { super({...options, property: options.property ?? 'textContent'}); setDirection(this.element); } public update(options?: IntlDateElementOptions) { safeAssign(this, options); let text: string; if(this.options.hour && this.options.minute && Object.keys(this.options).length === 2/* && false */) { const hours = this.date.getHours(); text = ('0' + (timeFormat === 'h12' ? (hours % 12) || 12 : hours)).slice(-2) + ':' + ('0' + this.date.getMinutes()).slice(-2); // if(this.options.second) { // text += ':' + ('0' + this.date.getSeconds()).slice(-2); // } if(timeFormat === 'h12') { text += ' ' + (hours < 12 ? amPmCache.am : amPmCache.pm); } } else { // * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/hourCycle#adding_an_hour_cycle_via_the_locale_string const dateTimeFormat = getDateTimeFormat(this.options); text = capitalizeFirstLetter(dateTimeFormat.format(this.date)); } (this.element as any)[this.property] = text; } } export function i18n(key: LangPackKey, args?: FormatterArguments) { return new IntlElement({key, args}).element; } export function i18n_(options: IntlElementOptions) { return new IntlElement(options).element; } export function _i18n(element: HTMLElement, key: LangPackKey, args?: FormatterArguments, property?: IntlElementOptions['property']) { return new IntlElement({element, key, args, property}).element; } } export {I18n}; export default I18n; const i18n = I18n.i18n; export {i18n}; const i18n_ = I18n.i18n_; export {i18n_}; const _i18n = I18n._i18n; export {_i18n}; export function joinElementsWith(elements: (Node | string)[], joiner: typeof elements[0] | ((isLast: boolean) => typeof elements[0])) { const arr = elements.slice(0, 1); for(let i = 1; i < elements.length; ++i) { const isLast = (elements.length - 1) === i; arr.push(typeof(joiner) === 'function' ? joiner(isLast) : joiner); arr.push(elements[i]); } return arr; } export function join(elements: (Node | string)[], useLast: boolean, plain: true): string; export function join(elements: (Node | string)[], useLast?: boolean, plain?: false): (string | Node)[]; export function join(elements: (Node | string)[], useLast: boolean, plain: boolean): string | (string | Node)[]; export function join(elements: (Node | string)[], useLast = true, plain?: boolean): string | (string | Node)[] { const joined = joinElementsWith(elements, (isLast) => { const langPackKey: LangPackKey = isLast && useLast ? 'AutoDownloadSettings.LastDelimeter' : 'AutoDownloadSettings.Delimeter'; return plain ? I18n.format(langPackKey, true) : i18n(langPackKey); }); return plain ? joined.join('') : joined; } MOUNT_CLASS_TO.I18n = I18n;