snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
Some checks failed
Prosody Plugin Tests / test (push) Failing after 10m11s
Close stale issues and PRs / stale (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-15 15:56:32 +05:00
commit 1d2bfcac0c
3159 changed files with 464524 additions and 0 deletions

View File

@@ -0,0 +1,189 @@
import i18next from 'i18next';
/**
* The builtin languages.
*/
const _LANGUAGES = {
// Afrikaans
'af': {
main: require('../../../../lang/main-af')
},
// Arabic
'ar': {
main: require('../../../../lang/main-ar')
},
// Bulgarian
'bg': {
main: require('../../../../lang/main-bg')
},
// Catalan
'ca': {
main: require('../../../../lang/main-ca')
},
// German
'de': {
main: require('../../../../lang/main-de')
},
// Esperanto
'eo': {
main: require('../../../../lang/main-eo')
},
// Spanish
'es': {
main: require('../../../../lang/main-es')
},
// Spanish (Latin America)
'es-US': {
main: require('../../../../lang/main-es-US')
},
// Estonian
'et': {
main: require('../../../../lang/main-et')
},
// Persian
'fa': {
main: require('../../../../lang/main-fa')
},
// Finnish
'fi': {
main: require('../../../../lang/main-fi')
},
// French
'fr': {
main: require('../../../../lang/main-fr')
},
// French (Canadian)
'fr-CA': {
main: require('../../../../lang/main-fr-CA')
},
// Croatian
'hr': {
main: require('../../../../lang/main-hr')
},
// Hungarian
'hu': {
main: require('../../../../lang/main-hu')
},
// Italian
'it': {
main: require('../../../../lang/main-it')
},
// Japanese
'ja': {
main: require('../../../../lang/main-ja')
},
// Korean
'ko': {
main: require('../../../../lang/main-ko')
},
// Mongolian
'mn': {
main: require('../../../../lang/main-mn')
},
// Dutch
'nl': {
main: require('../../../../lang/main-nl')
},
// Occitan
'oc': {
main: require('../../../../lang/main-oc')
},
// Polish
'pl': {
main: require('../../../../lang/main-pl')
},
// Portuguese (Brazil)
'pt-BR': {
main: require('../../../../lang/main-pt-BR')
},
// Romanian
'ro': {
main: require('../../../../lang/main-ro')
},
// Russian
'ru': {
main: require('../../../../lang/main-ru')
},
// Sardinian (Sardinia)
'sc': {
main: require('../../../../lang/main-sc')
},
// Slovak
'sk': {
main: require('../../../../lang/main-sk')
},
// Slovenian
'sl': {
main: require('../../../../lang/main-sl')
},
// Swedish
'sv': {
main: require('../../../../lang/main-sv')
},
// Turkish
'tr': {
main: require('../../../../lang/main-tr')
},
// Ukrainian
'uk': {
main: require('../../../../lang/main-uk')
},
// Vietnamese
'vi': {
main: require('../../../../lang/main-vi')
},
// Chinese (Simplified)
'zh-CN': {
main: require('../../../../lang/main-zh-CN')
},
// Chinese (Traditional)
'zh-TW': {
main: require('../../../../lang/main-zh-TW')
}
};
// Register all builtin languages with the i18n library.
for (const name in _LANGUAGES) { // eslint-disable-line guard-for-in
const { main } = _LANGUAGES[name as keyof typeof _LANGUAGES];
i18next.addResourceBundle(
name,
'main',
main,
/* deep */ true,
/* overwrite */ true);
}

View File

@@ -0,0 +1,9 @@
/**
* The type of (redux) action which signals that i18next has been initialized.
*/
export const I18NEXT_INITIALIZED = 'I18NEXT_INITIALIZED';
/**
* The type of (redux) action which signals that language has been changed.
*/
export const LANGUAGE_CHANGED = 'LANGUAGE_CHANGED';

View File

@@ -0,0 +1,27 @@
import { noop } from 'lodash-es';
/**
* Custom language detection, just returns the config property if any.
*
* When config.js has not yet been loaded (e.g. SSI did not inject it and we
* are fetching it asynchronously via appNavigate), this returns undefined so
* other detectors in the chain take over. Once SET_CONFIG dispatches, the
* i18n middleware applies defaultLanguage from the store via changeLanguage.
*/
export default {
cacheUserLanguage: noop,
/**
* Looks the language up in the config.
*
* @returns {string | undefined} The default language if available.
*/
lookup() {
return window.config?.defaultLanguage;
},
/**
* Name of the language detector.
*/
name: 'configLanguageDetector'
};

View File

@@ -0,0 +1,161 @@
import dayjs from 'dayjs';
import durationPlugin from 'dayjs/plugin/duration';
import localizedFormatPlugin from 'dayjs/plugin/localizedFormat';
import relativeTimePlugin from 'dayjs/plugin/relativeTime';
import i18next from './i18next';
dayjs.extend(durationPlugin);
dayjs.extend(relativeTimePlugin);
dayjs.extend(localizedFormatPlugin);
// Day.js uses static language bundle loading, so in order to support dynamic
// language selection in the app we need to load all bundles that we support in
// the app.
import 'dayjs/locale/af';
import 'dayjs/locale/ar';
import 'dayjs/locale/be';
import 'dayjs/locale/bg';
import 'dayjs/locale/ca';
import 'dayjs/locale/cs';
import 'dayjs/locale/da';
import 'dayjs/locale/de';
import 'dayjs/locale/el';
import 'dayjs/locale/eo';
import 'dayjs/locale/es';
import 'dayjs/locale/es-us';
import 'dayjs/locale/et';
import 'dayjs/locale/eu';
import 'dayjs/locale/fa';
import 'dayjs/locale/fi';
import 'dayjs/locale/fr';
import 'dayjs/locale/fr-ca';
import 'dayjs/locale/gl';
import 'dayjs/locale/he';
import 'dayjs/locale/hi';
import 'dayjs/locale/hr';
import 'dayjs/locale/hu';
import 'dayjs/locale/hy-am';
import 'dayjs/locale/id';
import 'dayjs/locale/is';
import 'dayjs/locale/it';
import 'dayjs/locale/ja';
import 'dayjs/locale/ko';
import 'dayjs/locale/lt';
import 'dayjs/locale/lv';
import 'dayjs/locale/ml';
import 'dayjs/locale/mn';
import 'dayjs/locale/mr';
import 'dayjs/locale/nb';
import 'dayjs/locale/nl';
import 'dayjs/locale/oc-lnc';
import 'dayjs/locale/pl';
import 'dayjs/locale/pt';
import 'dayjs/locale/pt-br';
import 'dayjs/locale/ro';
import 'dayjs/locale/ru';
import 'dayjs/locale/sk';
import 'dayjs/locale/sl';
import 'dayjs/locale/sq';
import 'dayjs/locale/sr';
import 'dayjs/locale/sv';
import 'dayjs/locale/te';
import 'dayjs/locale/tr';
import 'dayjs/locale/uk';
import 'dayjs/locale/vi';
import 'dayjs/locale/zh-cn';
import 'dayjs/locale/zh-tw';
const LOCALE_MAPPING: Record<string, string> = {
// i18next -> dayjs
'hy': 'hy-am',
'oc': 'oc-lnc',
'zhCN': 'zh-cn',
'zhTW': 'zh-tw',
'ptBR': 'pt-br',
'esUS': 'es-us',
'frCA': 'fr-ca'
};
/**
* Returns a localized date formatter initialized with a specific {@code Date}
* or timestamp ({@code number}).
*
* @private
* @param {Date | number} dateOrTimeStamp - The date or unix timestamp (ms)
* to format.
* @returns {Object}
*/
export function getLocalizedDateFormatter(dateOrTimeStamp: Date | number) {
return dayjs(dateOrTimeStamp).locale(_getSupportedLocale());
}
/**
* Formats a duration in milliseconds as a human-readable timer string.
*
* Output depends on length.
* - Under 1 hour: {@code MM:SS}
* - 1 hour or more (under 1 day): {@code H:MM:SS}
* - 1 day or more: {@code Dd HH:MM:SS}.
*
* @private
* @param {number} duration - The duration in milliseconds to format.
* @returns {string} The formatted duration string.
*/
export function getLocalizedDurationFormatter(duration: number) {
// Format duration with increasing precision by length: MM:SS under an hour,
// H:MM:SS from one hour up, and Dd HH:MM:SS once the duration reaches a day.
const d = dayjs.duration(duration);
const totalDays = Math.floor(d.asDays());
const hours = d.hours();
const minutes = d.minutes();
const seconds = d.seconds();
const pad = (n: number) => String(n).padStart(2, '0');
if (totalDays > 0) {
return `${totalDays}d ${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
}
if (hours > 0) {
return `${hours}:${pad(minutes)}:${pad(seconds)}`;
}
return `${pad(minutes)}:${pad(seconds)}`;
}
/**
* A lenient locale matcher to match language and dialect if possible.
*
* @private
* @returns {string}
*/
function _getSupportedLocale() {
const availableLocales = Object.keys(dayjs.Ls);
const i18nLocale = i18next.language;
let supportedLocale;
if (LOCALE_MAPPING[i18nLocale]) {
return LOCALE_MAPPING[i18nLocale];
}
if (availableLocales.includes(i18nLocale)) {
return i18nLocale;
}
if (i18nLocale) {
const localeRegexp = new RegExp('^([a-z]{2,2})(-)*([a-z]{2,2})*$');
const localeResult = localeRegexp.exec(i18nLocale.toLowerCase());
if (localeResult) {
const currentLocaleRegexp
= new RegExp(
`^${localeResult[1]}(-)*${`(${localeResult[3]})*` || ''}`);
supportedLocale
= availableLocales.find(lang => currentLocaleRegexp.exec(lang));
}
}
return supportedLocale || 'en';
}

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { WithTranslation, withTranslation } from 'react-i18next';
import i18next from './i18next';
/**
* Changes the main translation bundle.
*
* @param {string} language - The language e.g. 'en', 'fr'.
* @param {string} url - The url of the translation bundle.
* @param {string} ns - The namespace of the translation bundle.
* @returns {void}
*/
export async function changeLanguageBundle(language: string, url: string, ns = 'main') {
const res = await fetch(url);
const bundle = await res.json();
i18next.addResourceBundle(language, ns, bundle, true, true);
}
/**
* Wraps a specific React Component in order to enable translations in it.
*
* @param {Component} component - The React Component to wrap.
* @returns {Component} The React Component which wraps {@link component} and
* enables translations in it.
*/
export function translate<P extends WithTranslation>(component: React.ComponentType<P>) {
// Use the default list of namespaces.
return withTranslation([ 'main', 'languages', 'countries' ])(component);
}

View File

@@ -0,0 +1,19 @@
import React from 'react';
import { Text } from 'react-native';
export { changeLanguageBundle, translate } from './functions.any';
/**
* Translates a specific key to text containing HTML via a specific translate
* function. On native, HTML tags are stripped and the plain text is rendered.
*
* @param {Function} t - The translate function.
* @param {string} key - The key to translate.
* @param {Record<string, unknown>} options - The options, if any, to pass to {@link t}.
* @returns {ReactElement} A ReactElement which depicts the translated text.
*/
export function translateToHTML(t: Function, key: string, options: Record<string, unknown> = {}) {
const text = t(key, options).replace(/<[^>]*>/g, '');
return <Text>{ text }</Text>;
}

View File

@@ -0,0 +1,67 @@
import DOMPurify from 'dompurify';
import React from 'react';
export { changeLanguageBundle, translate } from './functions.any';
const SANITIZE_CONFIG = {
ALLOWED_TAGS: [ 'a', 'b', 'br', 'span' ],
// `target` and `rel` are intentionally not allowed here: they are forced to
// safe values by the hook below so a translation cannot control them.
ALLOWED_ATTR: [ 'href' ],
ALLOWED_URI_REGEXP: /^https?:\/\//i
};
// A dedicated DOMPurify instance so the link-hardening hook below stays scoped
// to translated HTML and does not leak into other consumers of the shared
// instance (e.g. branding SVG sanitizing).
const purify = DOMPurify(window);
// Force links produced from (potentially translator-supplied) strings to open
// in a new browsing context. This prevents a translation from navigating the
// user out of an ongoing meeting via target="_self" (or "_top"/"_parent", or by
// omitting target, which defaults to same-context navigation) and pairs the new
// tab with rel="noopener noreferrer" to prevent reverse tabnabbing.
purify.addHook('afterSanitizeAttributes', (node: Element) => {
if (node.tagName === 'A') {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
});
/**
* Escapes a string for safe inclusion in HTML.
*
* @param {string} value - The string to escape.
* @returns {string} The escaped string.
*/
function escapeHTML(value: string): string {
const el = document.createElement('span');
el.textContent = value;
return el.innerHTML;
}
/**
* Translates a specific key to text containing HTML via a specific translate
* function.
*
* @param {Function} t - The translate function.
* @param {string} key - The key to translate.
* @param {Record<string, unknown>} options - The options, if any, to pass to {@link t}.
* @returns {ReactElement} A ReactElement which depicts the translated HTML
* text.
*/
export function translateToHTML(t: Function, key: string, options: Record<string, unknown> = {}) {
const escapedOptions: Record<string, unknown> = {};
for (const [ k, v ] of Object.entries(options)) {
escapedOptions[k] = typeof v === 'string' ? escapeHTML(v) : v;
}
const html = purify.sanitize(t(key, escapedOptions), SANITIZE_CONFIG);
// eslint-disable-next-line react/no-danger
return <span dangerouslySetInnerHTML = {{ __html: html }} />;
}

View File

@@ -0,0 +1,155 @@
import COUNTRIES_RESOURCES from 'i18n-iso-countries/langs/en.json';
import i18next from 'i18next';
import I18nextXHRBackend, { HttpBackendOptions } from 'i18next-http-backend';
import { merge } from 'lodash-es';
import LANGUAGES_RESOURCES from '../../../../lang/languages.json';
import MAIN_RESOURCES from '../../../../lang/main.json';
import TRANSLATION_LANGUAGES_RESOURCES from '../../../../lang/translation-languages.json';
import { I18NEXT_INITIALIZED, LANGUAGE_CHANGED } from './actionTypes';
import languageDetector from './languageDetector';
/**
* Override certain country names.
*/
const COUNTRIES_RESOURCES_OVERRIDES = {
countries: {
TW: 'Taiwan'
}
};
/**
* Merged country names.
*/
const COUNTRIES = merge({}, COUNTRIES_RESOURCES, COUNTRIES_RESOURCES_OVERRIDES);
/**
* The available/supported languages.
*
* @public
* @type {Array<string>}
*/
export const LANGUAGES: Array<string> = Object.keys(LANGUAGES_RESOURCES);
/**
* The available/supported translation languages.
*
* @public
* @type {Array<string>}
*/
export const TRANSLATION_LANGUAGES: Array<string> = Object.keys(TRANSLATION_LANGUAGES_RESOURCES);
/**
* The default language.
*
* English is the default language.
*
* @public
* @type {string} The default language.
*/
export const DEFAULT_LANGUAGE = 'en';
/**
* The available/supported translation languages head. (Languages displayed on the top ).
*
* @public
* @type {Array<string>}
*/
export const TRANSLATION_LANGUAGES_HEAD: Array<string> = [ DEFAULT_LANGUAGE ];
/**
* The available/supported i18n namespaces.
*
* @public
* @type {Array<string>}
*/
export const SUPPORTED_NS = [ 'main', 'languages', 'countries', 'translation-languages' ];
/**
* The options to initialize i18next with.
*
* @type {i18next.InitOptions}
*/
const options: i18next.InitOptions = {
backend: <HttpBackendOptions>{
loadPath: (lng: string[], ns: string[]) => {
switch (ns[0]) {
case 'countries':
case 'main':
return 'lang/{{ns}}-{{lng}}.json';
default:
return 'lang/{{ns}}.json';
}
}
},
defaultNS: 'main',
fallbackLng: DEFAULT_LANGUAGE,
interpolation: {
escapeValue: false // not needed for react as it escapes by default
},
load: 'all',
ns: SUPPORTED_NS,
react: {
// re-render when a new resource bundle is added
// @ts-expect-error. Fixed in i18next 19.6.1.
bindI18nStore: 'added',
useSuspense: false
},
returnEmptyString: false,
returnNull: false,
// XXX i18next modifies the array lngWhitelist so make sure to clone
// LANGUAGES.
whitelist: LANGUAGES.slice()
};
i18next
.use(navigator.product === 'ReactNative' ? {} : I18nextXHRBackend)
.use(languageDetector)
.init(options);
// Add default language which is preloaded from the source code.
i18next.addResourceBundle(
DEFAULT_LANGUAGE,
'countries',
COUNTRIES,
/* deep */ true,
/* overwrite */ true);
i18next.addResourceBundle(
DEFAULT_LANGUAGE,
'languages',
LANGUAGES_RESOURCES,
/* deep */ true,
/* overwrite */ true);
i18next.addResourceBundle(
DEFAULT_LANGUAGE,
'translation-languages',
TRANSLATION_LANGUAGES_RESOURCES,
/* deep */ true,
/* overwrite */ true);
i18next.addResourceBundle(
DEFAULT_LANGUAGE,
'main',
MAIN_RESOURCES,
/* deep */ true,
/* overwrite */ true);
// Add builtin languages.
// XXX: Note we are using require here, because we want the side-effects of the
// import, but imports can only be placed at the top, and it would be too early,
// since i18next is not yet initialized at that point.
require('./BuiltinLanguages');
// Label change through dynamic branding is available only for web
if (typeof APP !== 'undefined') {
i18next.on('initialized', () => {
APP.store.dispatch({ type: I18NEXT_INITIALIZED });
});
i18next.on('languageChanged', () => {
APP.store.dispatch({ type: LANGUAGE_CHANGED });
});
}
export default i18next;

View File

@@ -0,0 +1,43 @@
import { NativeModules } from 'react-native';
import LANGUAGES_RESOURCES from '../../../../lang/languages.json';
const LANGUAGES = Object.keys(LANGUAGES_RESOURCES);
/**
* The singleton language detector for React Native which uses the system-wide
* locale.
*/
export default {
/**
* Does not support caching.
*
* @returns {void}
*/
cacheUserLanguage: Function.prototype,
detect() {
const { LocaleDetector } = NativeModules;
const parts = LocaleDetector.locale.replace(/_/, '-').split('-');
const [ lang, regionOrScript, region ] = parts;
let locale;
if (parts.length >= 3) {
locale = `${lang}-${region}`;
} else if (parts.length === 2) {
locale = `${lang}-${regionOrScript}`;
} else {
locale = lang;
}
if (LANGUAGES.includes(locale)) {
return locale;
}
return lang;
},
init: Function.prototype,
type: 'languageDetector'
};

View File

@@ -0,0 +1,70 @@
import BrowserLanguageDetector from 'i18next-browser-languagedetector';
import { noop } from 'lodash-es';
import LANGUAGES_RESOURCES from '../../../../lang/languages.json';
import configLanguageDetector from './configLanguageDetector';
// Map from lowercased key → original key (e.g. 'fr-ca' → 'fr-CA') for
// case-insensitive lookup that still returns the exact casing i18next expects.
const SUPPORTED_LANGUAGES = new Map(
Object.keys(LANGUAGES_RESOURCES).map(lang => [ lang.toLowerCase(), lang ])
);
/**
* Custom querystring language detector that validates the raw `lang` query
* parameter against the application's supported-language allowlist before
* returning it. This prevents unsanitized user-controlled input from reaching
* i18next internals (defence-in-depth on top of i18next's own whitelist).
*/
const safeLangQueryDetector = {
name: 'safeLangQuery',
cacheUserLanguage: noop,
lookup() {
const value = new URLSearchParams(window.location.search).get('lang');
const lang = value && SUPPORTED_LANGUAGES.get(value.toLowerCase());
if (lang) {
return lang;
}
return undefined;
}
};
/**
* The ordered list (by name) of language detectors to be utilized as backends
* by the singleton language detector for Web.
*
* @type {Array<string>}
*/
const order = [
safeLangQueryDetector.name,
'localStorage'
];
// Allow i18next to detect the system language reported by the Web browser
// itself.
interfaceConfig.LANG_DETECTION && order.push('navigator');
// Default use configured language
order.push(configLanguageDetector.name);
/**
* The singleton language detector for Web.
*/
const languageDetector
= new BrowserLanguageDetector(
/* services */ null,
/* options */ {
caches: [ 'localStorage' ],
lookupLocalStorage: 'language',
lookupQuerystring: 'lang',
order
});
languageDetector.addDetector(safeLangQueryDetector);
languageDetector.addDetector(configLanguageDetector);
export default languageDetector;

View File

@@ -0,0 +1,3 @@
import { getLogger } from '../logging/functions';
export default getLogger('app:i18n');

View File

@@ -0,0 +1,76 @@
import { SET_DYNAMIC_BRANDING_DATA } from '../../dynamic-branding/actionTypes';
import { getConferenceState } from '../conference/functions';
import { SET_CONFIG } from '../config/actionTypes';
import MiddlewareRegistry from '../redux/MiddlewareRegistry';
import { I18NEXT_INITIALIZED, LANGUAGE_CHANGED } from './actionTypes';
import { changeLanguageBundle } from './functions';
import i18next, { SUPPORTED_NS } from './i18next';
import logger from './logger';
/**
* Implements the entry point of the middleware of the feature base/i18n.
*
* @param {Store} store - The redux store.
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case SET_CONFIG: {
// When config.js is fetched asynchronously (no window.config at boot),
// configLanguageDetector returns undefined and i18next falls back. Once
// the store has the config, apply its defaultLanguage if it differs
// from what i18next picked.
const defaultLanguage = action.config?.defaultLanguage;
if (defaultLanguage && defaultLanguage !== i18next.language) {
i18next.changeLanguage(defaultLanguage).catch(err => {
logger.log('Error applying defaultLanguage from config', err);
});
}
break;
}
case I18NEXT_INITIALIZED:
case LANGUAGE_CHANGED:
case SET_DYNAMIC_BRANDING_DATA: {
const { language } = i18next;
const data = action.type === SET_DYNAMIC_BRANDING_DATA
? action.value
: store.getState()['features/dynamic-branding'];
const labels = data?.labels;
if (language && labels?.[language]) {
changeLanguageBundle(language, labels[language])
.catch(err => {
logger.log('Error setting dynamic language bundle', err);
});
}
SUPPORTED_NS.forEach(ns => {
const nsLabels = data?.[`labels-${ns}`];
if (language && nsLabels?.[language]) {
changeLanguageBundle(language, nsLabels[language], ns)
.catch(err => {
logger.log(`Error setting dynamic language bundle for ${ns}`, err);
});
}
});
// Update transcription language, if applicable.
if (action.type === SET_DYNAMIC_BRANDING_DATA) {
const { defaultTranscriptionLanguage } = action.value;
if (typeof defaultTranscriptionLanguage !== 'undefined') {
const { conference } = getConferenceState(store.getState());
conference?.setTranscriptionLanguage(defaultTranscriptionLanguage);
}
}
break;
}
}
return next(action);
});