mirror of https://github.com/Siphonay/mastodon
Fix `/share` and cleanup and reorganize frontend locale loading (#25240)
This commit is contained in:
parent
5fae2de454
commit
b0780cfeed
|
@ -61,7 +61,7 @@ docker-compose.override.yml
|
||||||
/app/javascript/mastodon/features/emoji/emoji_map.json
|
/app/javascript/mastodon/features/emoji/emoji_map.json
|
||||||
|
|
||||||
# Ignore locale files
|
# Ignore locale files
|
||||||
/app/javascript/mastodon/locales
|
/app/javascript/mastodon/locales/*.json
|
||||||
/config/locales
|
/config/locales
|
||||||
|
|
||||||
# Ignore vendored CSS reset
|
# Ignore vendored CSS reset
|
||||||
|
|
|
@ -11,7 +11,7 @@ module ReactComponentHelper
|
||||||
end
|
end
|
||||||
|
|
||||||
def react_admin_component(name, props = {})
|
def react_admin_component(name, props = {})
|
||||||
data = { 'admin-component': name.to_s.camelcase, props: Oj.dump({ locale: I18n.locale }.merge(props)) }
|
data = { 'admin-component': name.to_s.camelcase, props: Oj.dump(props) }
|
||||||
div_tag_with_data(data)
|
div_tag_with_data(data)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -24,8 +24,6 @@ import {
|
||||||
fillListTimelineGaps,
|
fillListTimelineGaps,
|
||||||
} from './timelines';
|
} from './timelines';
|
||||||
|
|
||||||
const { messages } = getLocale();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {number} max
|
* @param {number} max
|
||||||
* @returns {number}
|
* @returns {number}
|
||||||
|
@ -43,8 +41,10 @@ const randomUpTo = max =>
|
||||||
* @param {function(object): boolean} [options.accept]
|
* @param {function(object): boolean} [options.accept]
|
||||||
* @returns {function(): void}
|
* @returns {function(): void}
|
||||||
*/
|
*/
|
||||||
export const connectTimelineStream = (timelineId, channelName, params = {}, options = {}) =>
|
export const connectTimelineStream = (timelineId, channelName, params = {}, options = {}) => {
|
||||||
connectStream(channelName, params, (dispatch, getState) => {
|
const { messages } = getLocale();
|
||||||
|
|
||||||
|
return connectStream(channelName, params, (dispatch, getState) => {
|
||||||
const locale = getState().getIn(['meta', 'locale']);
|
const locale = getState().getIn(['meta', 'locale']);
|
||||||
|
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
|
@ -121,6 +121,7 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Function} dispatch
|
* @param {Function} dispatch
|
||||||
|
|
|
@ -1,24 +1,19 @@
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { PureComponent } from 'react';
|
import { PureComponent } from 'react';
|
||||||
|
|
||||||
import { IntlProvider } from 'react-intl';
|
import { IntlProvider } from 'mastodon/locales';
|
||||||
|
|
||||||
import { getLocale, onProviderError } from '../locales';
|
|
||||||
|
|
||||||
const { messages } = getLocale();
|
|
||||||
|
|
||||||
export default class AdminComponent extends PureComponent {
|
export default class AdminComponent extends PureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
locale: PropTypes.string.isRequired,
|
|
||||||
children: PropTypes.node.isRequired,
|
children: PropTypes.node.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { locale, children } = this.props;
|
const { children } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IntlProvider locale={locale} messages={messages} onError={onProviderError}>
|
<IntlProvider>
|
||||||
{children}
|
{children}
|
||||||
</IntlProvider>
|
</IntlProvider>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,18 +1,14 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { PureComponent } from 'react';
|
import { PureComponent } from 'react';
|
||||||
|
|
||||||
import { IntlProvider } from 'react-intl';
|
|
||||||
|
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
|
|
||||||
import { fetchCustomEmojis } from '../actions/custom_emojis';
|
import { fetchCustomEmojis } from '../actions/custom_emojis';
|
||||||
import { hydrateStore } from '../actions/store';
|
import { hydrateStore } from '../actions/store';
|
||||||
import Compose from '../features/standalone/compose';
|
import Compose from '../features/standalone/compose';
|
||||||
import initialState from '../initial_state';
|
import initialState from '../initial_state';
|
||||||
import { getLocale, onProviderError } from '../locales';
|
import { IntlProvider } from '../locales';
|
||||||
import { store } from '../store';
|
import { store } from '../store';
|
||||||
|
|
||||||
const { messages } = getLocale();
|
|
||||||
|
|
||||||
if (initialState) {
|
if (initialState) {
|
||||||
store.dispatch(hydrateStore(initialState));
|
store.dispatch(hydrateStore(initialState));
|
||||||
|
@ -20,17 +16,11 @@ if (initialState) {
|
||||||
|
|
||||||
store.dispatch(fetchCustomEmojis());
|
store.dispatch(fetchCustomEmojis());
|
||||||
|
|
||||||
export default class TimelineContainer extends PureComponent {
|
export default class ComposeContainer extends PureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
locale: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { locale } = this.props;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IntlProvider locale={locale} messages={messages} onError={onProviderError}>
|
<IntlProvider>
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<Compose />
|
<Compose />
|
||||||
</Provider>
|
</Provider>
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { PureComponent } from 'react';
|
import { PureComponent } from 'react';
|
||||||
|
|
||||||
import { IntlProvider } from 'react-intl';
|
|
||||||
|
|
||||||
import { Helmet } from 'react-helmet';
|
import { Helmet } from 'react-helmet';
|
||||||
import { BrowserRouter, Route } from 'react-router-dom';
|
import { BrowserRouter, Route } from 'react-router-dom';
|
||||||
|
|
||||||
|
@ -16,11 +14,9 @@ import { connectUserStream } from 'mastodon/actions/streaming';
|
||||||
import ErrorBoundary from 'mastodon/components/error_boundary';
|
import ErrorBoundary from 'mastodon/components/error_boundary';
|
||||||
import UI from 'mastodon/features/ui';
|
import UI from 'mastodon/features/ui';
|
||||||
import initialState, { title as siteTitle } from 'mastodon/initial_state';
|
import initialState, { title as siteTitle } from 'mastodon/initial_state';
|
||||||
import { getLocale, onProviderError } from 'mastodon/locales';
|
import { IntlProvider } from 'mastodon/locales';
|
||||||
import { store } from 'mastodon/store';
|
import { store } from 'mastodon/store';
|
||||||
|
|
||||||
const { messages } = getLocale();
|
|
||||||
|
|
||||||
const title = process.env.NODE_ENV === 'production' ? siteTitle : `${siteTitle} (Dev)`;
|
const title = process.env.NODE_ENV === 'production' ? siteTitle : `${siteTitle} (Dev)`;
|
||||||
|
|
||||||
const hydrateAction = hydrateStore(initialState);
|
const hydrateAction = hydrateStore(initialState);
|
||||||
|
@ -40,10 +36,6 @@ const createIdentityContext = state => ({
|
||||||
|
|
||||||
export default class Mastodon extends PureComponent {
|
export default class Mastodon extends PureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
locale: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
static childContextTypes = {
|
static childContextTypes = {
|
||||||
identity: PropTypes.shape({
|
identity: PropTypes.shape({
|
||||||
signedIn: PropTypes.bool.isRequired,
|
signedIn: PropTypes.bool.isRequired,
|
||||||
|
@ -79,10 +71,8 @@ export default class Mastodon extends PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { locale } = this.props;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IntlProvider locale={locale} messages={messages} onError={onProviderError}>
|
<IntlProvider>
|
||||||
<ReduxProvider store={store}>
|
<ReduxProvider store={store}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
|
|
@ -2,8 +2,6 @@ import PropTypes from 'prop-types';
|
||||||
import { PureComponent } from 'react';
|
import { PureComponent } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import { IntlProvider } from 'react-intl';
|
|
||||||
|
|
||||||
import { fromJS } from 'immutable';
|
import { fromJS } from 'immutable';
|
||||||
|
|
||||||
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
|
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
|
||||||
|
@ -14,17 +12,14 @@ import Audio from 'mastodon/features/audio';
|
||||||
import Card from 'mastodon/features/status/components/card';
|
import Card from 'mastodon/features/status/components/card';
|
||||||
import MediaModal from 'mastodon/features/ui/components/media_modal';
|
import MediaModal from 'mastodon/features/ui/components/media_modal';
|
||||||
import Video from 'mastodon/features/video';
|
import Video from 'mastodon/features/video';
|
||||||
import { getLocale, onProviderError } from 'mastodon/locales';
|
import { IntlProvider } from 'mastodon/locales';
|
||||||
import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
|
import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
|
||||||
|
|
||||||
const { messages } = getLocale();
|
|
||||||
|
|
||||||
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio };
|
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio };
|
||||||
|
|
||||||
export default class MediaContainer extends PureComponent {
|
export default class MediaContainer extends PureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
locale: PropTypes.string.isRequired,
|
|
||||||
components: PropTypes.object.isRequired,
|
components: PropTypes.object.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -73,7 +68,7 @@ export default class MediaContainer extends PureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { locale, components } = this.props;
|
const { components } = this.props;
|
||||||
|
|
||||||
let handleOpenVideo;
|
let handleOpenVideo;
|
||||||
|
|
||||||
|
@ -83,7 +78,7 @@ export default class MediaContainer extends PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IntlProvider locale={locale} messages={messages} onError={onProviderError}>
|
<IntlProvider>
|
||||||
<>
|
<>
|
||||||
{[].map.call(components, (component, i) => {
|
{[].map.call(components, (component, i) => {
|
||||||
const componentName = component.getAttribute('data-component');
|
const componentName = component.getAttribute('data-component');
|
||||||
|
|
|
@ -1,14 +0,0 @@
|
||||||
import { setLocale } from "./locales";
|
|
||||||
|
|
||||||
export async function loadLocale() {
|
|
||||||
const locale = document.querySelector('html').lang || 'en';
|
|
||||||
|
|
||||||
const localeData = await import(
|
|
||||||
/* webpackMode: "lazy" */
|
|
||||||
/* webpackChunkName: "locale/[request]" */
|
|
||||||
/* webpackInclude: /\.json$/ */
|
|
||||||
/* webpackPreload: true */
|
|
||||||
`mastodon/locales/${locale}.json`);
|
|
||||||
|
|
||||||
setLocale({ messages: localeData });
|
|
||||||
}
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
export interface LocaleData {
|
||||||
|
locale: string;
|
||||||
|
messages: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let loadedLocale: LocaleData;
|
||||||
|
|
||||||
|
export function setLocale(locale: LocaleData) {
|
||||||
|
loadedLocale = locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocale() {
|
||||||
|
if (!loadedLocale && process.env.NODE_ENV === 'development') {
|
||||||
|
throw new Error('getLocale() called before any locale has been set');
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadedLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLocaleLoaded() {
|
||||||
|
return !!loadedLocale;
|
||||||
|
}
|
|
@ -1,22 +0,0 @@
|
||||||
let theLocale;
|
|
||||||
|
|
||||||
export function setLocale(locale) {
|
|
||||||
theLocale = locale;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLocale() {
|
|
||||||
return theLocale;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function onProviderError(error) {
|
|
||||||
// Silent the error, like upstream does
|
|
||||||
if(process.env.NODE_ENV === 'production') return;
|
|
||||||
|
|
||||||
// This browser does not advertise Intl support for this locale, we only print a warning
|
|
||||||
// As-per the spec, the browser should select the best matching locale
|
|
||||||
if(typeof error === "object" && error.message.match("MISSING_DATA")) {
|
|
||||||
console.warn(error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
export type { LocaleData } from './global_locale';
|
||||||
|
export { setLocale, getLocale, isLocaleLoaded } from './global_locale';
|
||||||
|
export { loadLocale } from './load_locale';
|
||||||
|
|
||||||
|
export { IntlProvider } from './intl_provider';
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { IntlProvider as BaseIntlProvider } from 'react-intl';
|
||||||
|
|
||||||
|
import { getLocale, isLocaleLoaded } from './global_locale';
|
||||||
|
import { loadLocale } from './load_locale';
|
||||||
|
|
||||||
|
function onProviderError(error: unknown) {
|
||||||
|
// Silent the error, like upstream does
|
||||||
|
if (process.env.NODE_ENV === 'production') return;
|
||||||
|
|
||||||
|
// This browser does not advertise Intl support for this locale, we only print a warning
|
||||||
|
// As-per the spec, the browser should select the best matching locale
|
||||||
|
if (
|
||||||
|
error &&
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error instanceof Error &&
|
||||||
|
error.message.match('MISSING_DATA')
|
||||||
|
) {
|
||||||
|
console.warn(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IntlProvider: React.FC<
|
||||||
|
Omit<React.ComponentProps<typeof BaseIntlProvider>, 'locale' | 'messages'>
|
||||||
|
> = ({ children, ...props }) => {
|
||||||
|
const [localeLoaded, setLocaleLoaded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadLocaleData() {
|
||||||
|
if (!isLocaleLoaded()) {
|
||||||
|
await loadLocale();
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocaleLoaded(true);
|
||||||
|
}
|
||||||
|
void loadLocaleData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!localeLoaded) return null;
|
||||||
|
|
||||||
|
const { locale, messages } = getLocale();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseIntlProvider
|
||||||
|
locale={locale}
|
||||||
|
messages={messages}
|
||||||
|
onError={onProviderError}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</BaseIntlProvider>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { Semaphore } from 'async-mutex';
|
||||||
|
|
||||||
|
import type { LocaleData } from './global_locale';
|
||||||
|
import { isLocaleLoaded, setLocale } from './global_locale';
|
||||||
|
|
||||||
|
const localeLoadingSemaphore = new Semaphore(1);
|
||||||
|
|
||||||
|
export async function loadLocale() {
|
||||||
|
const locale = document.querySelector<HTMLElement>('html')?.lang || 'en';
|
||||||
|
|
||||||
|
// We use a Semaphore here so only one thing can try to load the locales at
|
||||||
|
// the same time. If one tries to do it while its in progress, it will wait
|
||||||
|
// for the initial load to finish before it is resumed (and will see that locale
|
||||||
|
// data is already loaded)
|
||||||
|
await localeLoadingSemaphore.runExclusive(async () => {
|
||||||
|
// if the locale is already set, then do nothing
|
||||||
|
if (isLocaleLoaded()) return;
|
||||||
|
|
||||||
|
const localeData = (await import(
|
||||||
|
/* webpackMode: "lazy" */
|
||||||
|
/* webpackChunkName: "locale/[request]" */
|
||||||
|
/* webpackInclude: /\.json$/ */
|
||||||
|
/* webpackPreload: true */
|
||||||
|
`mastodon/locales/${locale}.json`
|
||||||
|
)) as LocaleData['messages'];
|
||||||
|
|
||||||
|
setLocale({ messages: localeData, locale });
|
||||||
|
});
|
||||||
|
}
|
|
@ -1,221 +0,0 @@
|
||||||
# Custom Locale Data
|
|
||||||
|
|
||||||
This folder is used to store custom locale data. These custom locale data are
|
|
||||||
not yet provided by [Unicode Common Locale Data Repository](http://cldr.unicode.org/development/new-cldr-developers)
|
|
||||||
and hence not provided in [react-intl/locale-data/*](https://github.com/yahoo/react-intl).
|
|
||||||
|
|
||||||
The locale data should support [Locale Data APIs](https://github.com/yahoo/react-intl/wiki/API#locale-data-apis)
|
|
||||||
of the react-intl library.
|
|
||||||
|
|
||||||
It is recommended to start your custom locale data from this sample English
|
|
||||||
locale data ([*](#plural-rules)):
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
/*eslint eqeqeq: "off"*/
|
|
||||||
/*eslint no-nested-ternary: "off"*/
|
|
||||||
|
|
||||||
export default [
|
|
||||||
{
|
|
||||||
locale: "en",
|
|
||||||
pluralRuleFunction: function(e, a) {
|
|
||||||
var n = String(e).split("."),
|
|
||||||
l = !n[1],
|
|
||||||
o = Number(n[0]) == e,
|
|
||||||
t = o && n[0].slice(-1),
|
|
||||||
r = o && n[0].slice(-2);
|
|
||||||
return a ? 1 == t && 11 != r ? "one" : 2 == t && 12 != r ? "two" : 3 == t && 13 != r ? "few" : "other" : 1 == e && l ? "one" : "other"
|
|
||||||
},
|
|
||||||
fields: {
|
|
||||||
year: {
|
|
||||||
displayName: "year",
|
|
||||||
relative: {
|
|
||||||
0: "this year",
|
|
||||||
1: "next year",
|
|
||||||
"-1": "last year"
|
|
||||||
},
|
|
||||||
relativeTime: {
|
|
||||||
future: {
|
|
||||||
one: "in {0} year",
|
|
||||||
other: "in {0} years"
|
|
||||||
},
|
|
||||||
past: {
|
|
||||||
one: "{0} year ago",
|
|
||||||
other: "{0} years ago"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
month: {
|
|
||||||
displayName: "month",
|
|
||||||
relative: {
|
|
||||||
0: "this month",
|
|
||||||
1: "next month",
|
|
||||||
"-1": "last month"
|
|
||||||
},
|
|
||||||
relativeTime: {
|
|
||||||
future: {
|
|
||||||
one: "in {0} month",
|
|
||||||
other: "in {0} months"
|
|
||||||
},
|
|
||||||
past: {
|
|
||||||
one: "{0} month ago",
|
|
||||||
other: "{0} months ago"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
day: {
|
|
||||||
displayName: "day",
|
|
||||||
relative: {
|
|
||||||
0: "today",
|
|
||||||
1: "tomorrow",
|
|
||||||
"-1": "yesterday"
|
|
||||||
},
|
|
||||||
relativeTime: {
|
|
||||||
future: {
|
|
||||||
one: "in {0} day",
|
|
||||||
other: "in {0} days"
|
|
||||||
},
|
|
||||||
past: {
|
|
||||||
one: "{0} day ago",
|
|
||||||
other: "{0} days ago"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
hour: {
|
|
||||||
displayName: "hour",
|
|
||||||
relativeTime: {
|
|
||||||
future: {
|
|
||||||
one: "in {0} hour",
|
|
||||||
other: "in {0} hours"
|
|
||||||
},
|
|
||||||
past: {
|
|
||||||
one: "{0} hour ago",
|
|
||||||
other: "{0} hours ago"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
minute: {
|
|
||||||
displayName: "minute",
|
|
||||||
relativeTime: {
|
|
||||||
future: {
|
|
||||||
one: "in {0} minute",
|
|
||||||
other: "in {0} minutes"
|
|
||||||
},
|
|
||||||
past: {
|
|
||||||
one: "{0} minute ago",
|
|
||||||
other: "{0} minutes ago"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
second: {
|
|
||||||
displayName: "second",
|
|
||||||
relative: {
|
|
||||||
0: "now"
|
|
||||||
},
|
|
||||||
relativeTime: {
|
|
||||||
future: {
|
|
||||||
one: "in {0} second",
|
|
||||||
other: "in {0} seconds"
|
|
||||||
},
|
|
||||||
past: {
|
|
||||||
one: "{0} second ago",
|
|
||||||
other: "{0} seconds ago"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
### Plural Rules
|
|
||||||
|
|
||||||
The function `pluralRuleFunction()` should return the key to proper string of
|
|
||||||
a plural form(s). The purpose of the function is to provide key of translate
|
|
||||||
strings of correct plural form according. The different forms are described in
|
|
||||||
[CLDR's Plural Rules][cldr-plural-rules],
|
|
||||||
|
|
||||||
[cldr-plural-rules]: http://cldr.unicode.org/index/cldr-spec/plural-rules
|
|
||||||
|
|
||||||
#### Quick Overview on CLDR Rules
|
|
||||||
|
|
||||||
Let's take English as an example.
|
|
||||||
|
|
||||||
When you describe a number, you can be either describe it as:
|
|
||||||
* Cardinals: 1st, 2nd, 3rd ... 11th, 12th ... 21st, 22nd, 23nd ....
|
|
||||||
* Ordinals: 1, 2, 3 ...
|
|
||||||
|
|
||||||
In any of these cases, the nouns will reflect the number with singular or plural
|
|
||||||
form. For example:
|
|
||||||
* in 0 days
|
|
||||||
* in 1 day
|
|
||||||
* in 2 days
|
|
||||||
|
|
||||||
The `pluralRuleFunction` receives 2 parameters:
|
|
||||||
* `e`: a string representation of the number. Such as, "`1`", "`2`", "`2.1`".
|
|
||||||
* `a`: `true` if this is "cardinal" type of description. `false` for ordinal and other case.
|
|
||||||
|
|
||||||
#### How you should write `pluralRuleFunction`
|
|
||||||
|
|
||||||
The first rule to write pluralRuleFunction is never translate the output string
|
|
||||||
into your language. [Plural Rules][cldr-plural-rules] specified you should use
|
|
||||||
these as the return values:
|
|
||||||
|
|
||||||
* "`zero`"
|
|
||||||
* "`one`" (singular)
|
|
||||||
* "`two`" (dual)
|
|
||||||
* "`few`" (paucal)
|
|
||||||
* "`many`" (also used for fractions if they have a separate class)
|
|
||||||
* "`other`" (required—general plural form—also used if the language only has a single form)
|
|
||||||
|
|
||||||
Again, we'll use English as the example here.
|
|
||||||
|
|
||||||
Let's read the `return` statement in the pluralRuleFunction above:
|
|
||||||
```javascript
|
|
||||||
return a ? 1 == t && 11 != r ? "one" : 2 == t && 12 != r ? "two" : 3 == t && 13 != r ? "few" : "other" : 1 == e && l ? "one" : "other"
|
|
||||||
```
|
|
||||||
|
|
||||||
This nested ternary is hard to read. It basically means:
|
|
||||||
```javascript
|
|
||||||
// e: the number variable to examine
|
|
||||||
// a: "true" if cardinals
|
|
||||||
// l: "true" if the variable e has nothin after decimal mark (e.g. "1.0" would be false)
|
|
||||||
// o: "true" if the variable e is an integer
|
|
||||||
// t: the "ones" of the number. e.g. "3" for number "9123"
|
|
||||||
// r: the "ones" and "tens" of the number. e.g. "23" for number "9123"
|
|
||||||
if (a == true) {
|
|
||||||
if (t == 1 && r != 11) {
|
|
||||||
return "one"; // i.e. 1st, 21st, 101st, 121st ...
|
|
||||||
} else if (t == 2 && r != 12) {
|
|
||||||
return "two"; // i.e. 2nd, 22nd, 102nd, 122nd ...
|
|
||||||
} else if (t == 3 && r != 13) {
|
|
||||||
return "few"; // i.e. 3rd, 23rd, 103rd, 123rd ...
|
|
||||||
} else {
|
|
||||||
return "other"; // i.e. 4th, 11th, 12th, 24th ...
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (e == 1 && l) {
|
|
||||||
return "one"; // i.e. 1 day
|
|
||||||
} else {
|
|
||||||
return "other"; // i.e. 0 days, 2 days, 3 days
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
If your language, like French, do not have complicated cardinal rules, you may
|
|
||||||
use the French's version of it:
|
|
||||||
```javascript
|
|
||||||
function (e, a) {
|
|
||||||
return a ? 1 == e ? "one" : "other" : e >= 0 && e < 2 ? "one" : "other";
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
If your language, like Chinese, do not have any pluralization rule at all you
|
|
||||||
may use the Chinese's version of it:
|
|
||||||
```javascript
|
|
||||||
function (e, a) {
|
|
||||||
return "other";
|
|
||||||
}
|
|
||||||
```
|
|
|
@ -1,110 +0,0 @@
|
||||||
/*eslint eqeqeq: "off"*/
|
|
||||||
/*eslint no-nested-ternary: "off"*/
|
|
||||||
/*eslint quotes: "off"*/
|
|
||||||
|
|
||||||
const rules = [{
|
|
||||||
locale: "co",
|
|
||||||
pluralRuleFunction: function (e, a) {
|
|
||||||
return a ? 1 == e ? "one" : "other" : e >= 0 && e < 2 ? "one" : "other";
|
|
||||||
},
|
|
||||||
fields: {
|
|
||||||
year: {
|
|
||||||
displayName: "annu",
|
|
||||||
relative: {
|
|
||||||
0: "quist'annu",
|
|
||||||
1: "l'annu chì vene",
|
|
||||||
"-1": "l'annu passatu",
|
|
||||||
},
|
|
||||||
relativeTime: {
|
|
||||||
future: {
|
|
||||||
one: "in {0} annu",
|
|
||||||
other: "in {0} anni",
|
|
||||||
},
|
|
||||||
past: {
|
|
||||||
one: "{0} annu fà",
|
|
||||||
other: "{0} anni fà",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
month: {
|
|
||||||
displayName: "mese",
|
|
||||||
relative: {
|
|
||||||
0: "Questu mese",
|
|
||||||
1: "u mese chì vene",
|
|
||||||
"-1": "u mese passatu",
|
|
||||||
},
|
|
||||||
relativeTime: {
|
|
||||||
future: {
|
|
||||||
one: "in {0} mese",
|
|
||||||
other: "in {0} mesi",
|
|
||||||
},
|
|
||||||
past: {
|
|
||||||
one: "{0} mese fà",
|
|
||||||
other: "{0} mesi fà",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
day: {
|
|
||||||
displayName: "ghjornu",
|
|
||||||
relative: {
|
|
||||||
0: "oghje",
|
|
||||||
1: "dumane",
|
|
||||||
"-1": "eri",
|
|
||||||
},
|
|
||||||
relativeTime: {
|
|
||||||
future: {
|
|
||||||
one: "in {0} ghjornu",
|
|
||||||
other: "in {0} ghjornu",
|
|
||||||
},
|
|
||||||
past: {
|
|
||||||
one: "{0} ghjornu fà",
|
|
||||||
other: "{0} ghjorni fà",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
hour: {
|
|
||||||
displayName: "ora",
|
|
||||||
relativeTime: {
|
|
||||||
future: {
|
|
||||||
one: "in {0} ora",
|
|
||||||
other: "in {0} ore",
|
|
||||||
},
|
|
||||||
past: {
|
|
||||||
one: "{0} ora fà",
|
|
||||||
other: "{0} ore fà",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
minute: {
|
|
||||||
displayName: "minuta",
|
|
||||||
relativeTime: {
|
|
||||||
future: {
|
|
||||||
one: "in {0} minuta",
|
|
||||||
other: "in {0} minute",
|
|
||||||
},
|
|
||||||
past: {
|
|
||||||
one: "{0} minuta fà",
|
|
||||||
other: "{0} minute fà",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
second: {
|
|
||||||
displayName: "siconda",
|
|
||||||
relative: {
|
|
||||||
0: "avà",
|
|
||||||
},
|
|
||||||
relativeTime: {
|
|
||||||
future: {
|
|
||||||
one: "in {0} siconda",
|
|
||||||
other: "in {0} siconde",
|
|
||||||
},
|
|
||||||
past: {
|
|
||||||
one: "{0} siconda fà",
|
|
||||||
other: "{0} siconde fà",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}];
|
|
||||||
|
|
||||||
export default rules;
|
|
|
@ -1,110 +0,0 @@
|
||||||
/*eslint eqeqeq: "off"*/
|
|
||||||
/*eslint no-nested-ternary: "off"*/
|
|
||||||
/*eslint quotes: "off"*/
|
|
||||||
|
|
||||||
const rules = [{
|
|
||||||
locale: "oc",
|
|
||||||
pluralRuleFunction: function (e, a) {
|
|
||||||
return a ? 1 == e ? "one" : "other" : e >= 0 && e < 2 ? "one" : "other";
|
|
||||||
},
|
|
||||||
fields: {
|
|
||||||
year: {
|
|
||||||
displayName: "an",
|
|
||||||
relative: {
|
|
||||||
0: "ongan",
|
|
||||||
1: "l'an que ven",
|
|
||||||
"-1": "l'an passat",
|
|
||||||
},
|
|
||||||
relativeTime: {
|
|
||||||
future: {
|
|
||||||
one: "d’aquí {0} an",
|
|
||||||
other: "d’aquí {0} ans",
|
|
||||||
},
|
|
||||||
past: {
|
|
||||||
one: "fa {0} an",
|
|
||||||
other: "fa {0} ans",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
month: {
|
|
||||||
displayName: "mes",
|
|
||||||
relative: {
|
|
||||||
0: "aqueste mes",
|
|
||||||
1: "lo mes que ven",
|
|
||||||
"-1": "lo mes passat",
|
|
||||||
},
|
|
||||||
relativeTime: {
|
|
||||||
future: {
|
|
||||||
one: "d’aquí {0} mes",
|
|
||||||
other: "d’aquí {0} meses",
|
|
||||||
},
|
|
||||||
past: {
|
|
||||||
one: "fa {0} mes",
|
|
||||||
other: "fa {0} meses",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
day: {
|
|
||||||
displayName: "jorn",
|
|
||||||
relative: {
|
|
||||||
0: "uèi",
|
|
||||||
1: "deman",
|
|
||||||
"-1": "ièr",
|
|
||||||
},
|
|
||||||
relativeTime: {
|
|
||||||
future: {
|
|
||||||
one: "d’aquí {0} jorn",
|
|
||||||
other: "d’aquí {0} jorns",
|
|
||||||
},
|
|
||||||
past: {
|
|
||||||
one: "fa {0} jorn",
|
|
||||||
other: "fa {0} jorns",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
hour: {
|
|
||||||
displayName: "ora",
|
|
||||||
relativeTime: {
|
|
||||||
future: {
|
|
||||||
one: "d’aquí {0} ora",
|
|
||||||
other: "d’aquí {0} oras",
|
|
||||||
},
|
|
||||||
past: {
|
|
||||||
one: "fa {0} ora",
|
|
||||||
other: "fa {0} oras",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
minute: {
|
|
||||||
displayName: "minuta",
|
|
||||||
relativeTime: {
|
|
||||||
future: {
|
|
||||||
one: "d’aquí {0} minuta",
|
|
||||||
other: "d’aquí {0} minutas",
|
|
||||||
},
|
|
||||||
past: {
|
|
||||||
one: "fa {0} minuta",
|
|
||||||
other: "fa {0} minutas",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
second: {
|
|
||||||
displayName: "segonda",
|
|
||||||
relative: {
|
|
||||||
0: "ara",
|
|
||||||
},
|
|
||||||
relativeTime: {
|
|
||||||
future: {
|
|
||||||
one: "d’aquí {0} segonda",
|
|
||||||
other: "d’aquí {0} segondas",
|
|
||||||
},
|
|
||||||
past: {
|
|
||||||
one: "fa {0} segonda",
|
|
||||||
other: "fa {0} segondas",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}];
|
|
||||||
|
|
||||||
export default rules;
|
|
|
@ -1,98 +0,0 @@
|
||||||
/*eslint eqeqeq: "off"*/
|
|
||||||
/*eslint no-nested-ternary: "off"*/
|
|
||||||
/*eslint quotes: "off"*/
|
|
||||||
/*eslint comma-dangle: "off"*/
|
|
||||||
|
|
||||||
const rules = [
|
|
||||||
{
|
|
||||||
locale: "sa",
|
|
||||||
fields: {
|
|
||||||
year: {
|
|
||||||
displayName: "year",
|
|
||||||
relative: {
|
|
||||||
0: "this year",
|
|
||||||
1: "next year",
|
|
||||||
"-1": "last year"
|
|
||||||
},
|
|
||||||
relativeTime: {
|
|
||||||
future: {
|
|
||||||
other: "+{0} y"
|
|
||||||
},
|
|
||||||
past: {
|
|
||||||
other: "-{0} y"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
month: {
|
|
||||||
displayName: "month",
|
|
||||||
relative: {
|
|
||||||
0: "this month",
|
|
||||||
1: "next month",
|
|
||||||
"-1": "last month"
|
|
||||||
},
|
|
||||||
relativeTime: {
|
|
||||||
future: {
|
|
||||||
other: "+{0} m"
|
|
||||||
},
|
|
||||||
past: {
|
|
||||||
other: "-{0} m"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
day: {
|
|
||||||
displayName: "day",
|
|
||||||
relative: {
|
|
||||||
0: "अद्य",
|
|
||||||
1: "श्वः",
|
|
||||||
"-1": "गतदिनम्"
|
|
||||||
},
|
|
||||||
relativeTime: {
|
|
||||||
future: {
|
|
||||||
other: "+{0} d"
|
|
||||||
},
|
|
||||||
past: {
|
|
||||||
other: "-{0} d"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
hour: {
|
|
||||||
displayName: "hour",
|
|
||||||
relativeTime: {
|
|
||||||
future: {
|
|
||||||
other: "+{0} h"
|
|
||||||
},
|
|
||||||
past: {
|
|
||||||
other: "-{0} h"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
minute: {
|
|
||||||
displayName: "minute",
|
|
||||||
relativeTime: {
|
|
||||||
future: {
|
|
||||||
other: "+{0} min"
|
|
||||||
},
|
|
||||||
past: {
|
|
||||||
other: "-{0} min"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
second: {
|
|
||||||
displayName: "second",
|
|
||||||
relative: {
|
|
||||||
0: "now"
|
|
||||||
},
|
|
||||||
relativeTime: {
|
|
||||||
future: {
|
|
||||||
other: "+{0} s"
|
|
||||||
},
|
|
||||||
past: {
|
|
||||||
other: "-{0} s"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export default rules;
|
|
|
@ -229,14 +229,14 @@ ready(() => {
|
||||||
|
|
||||||
[].forEach.call(document.querySelectorAll('[data-admin-component]'), element => {
|
[].forEach.call(document.querySelectorAll('[data-admin-component]'), element => {
|
||||||
const componentName = element.getAttribute('data-admin-component');
|
const componentName = element.getAttribute('data-admin-component');
|
||||||
const { locale, ...componentProps } = JSON.parse(element.getAttribute('data-props'));
|
const componentProps = JSON.parse(element.getAttribute('data-props'));
|
||||||
|
|
||||||
import('../mastodon/containers/admin_component').then(({ default: AdminComponent }) => {
|
import('../mastodon/containers/admin_component').then(({ default: AdminComponent }) => {
|
||||||
return import('../mastodon/components/admin/' + componentName).then(({ default: Component }) => {
|
return import('../mastodon/components/admin/' + componentName).then(({ default: Component }) => {
|
||||||
const root = createRoot(element);
|
const root = createRoot(element);
|
||||||
|
|
||||||
root.render (
|
root.render (
|
||||||
<AdminComponent locale={locale}>
|
<AdminComponent>
|
||||||
<Component {...componentProps} />
|
<Component {...componentProps} />
|
||||||
</AdminComponent>,
|
</AdminComponent>,
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,14 +1,15 @@
|
||||||
import './public-path';
|
import './public-path';
|
||||||
|
import main from "mastodon/main"
|
||||||
|
|
||||||
import { start } from '../mastodon/common';
|
import { start } from '../mastodon/common';
|
||||||
import { loadLocale } from '../mastodon/load_locale';
|
import { loadLocale } from '../mastodon/locales';
|
||||||
import { loadPolyfills } from '../mastodon/polyfills';
|
import { loadPolyfills } from '../mastodon/polyfills';
|
||||||
|
|
||||||
start();
|
start();
|
||||||
|
|
||||||
loadPolyfills().then(loadLocale).then(async () => {
|
loadPolyfills()
|
||||||
const { default: main } = await import('mastodon/main');
|
.then(loadLocale)
|
||||||
|
.then(main)
|
||||||
return main();
|
.catch(e => {
|
||||||
}).catch(e => {
|
console.error(e);
|
||||||
console.error(e);
|
});
|
||||||
});
|
|
||||||
|
|
|
@ -15,8 +15,7 @@ import { start } from '../mastodon/common';
|
||||||
import { timeAgoString } from '../mastodon/components/relative_timestamp';
|
import { timeAgoString } from '../mastodon/components/relative_timestamp';
|
||||||
import emojify from '../mastodon/features/emoji/emoji';
|
import emojify from '../mastodon/features/emoji/emoji';
|
||||||
import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions';
|
import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions';
|
||||||
import { loadLocale } from '../mastodon/load_locale';
|
import { loadLocale, getLocale } from '../mastodon/locales';
|
||||||
import { getLocale } from '../mastodon/locales';
|
|
||||||
import { loadPolyfills } from '../mastodon/polyfills';
|
import { loadPolyfills } from '../mastodon/polyfills';
|
||||||
import ready from '../mastodon/ready';
|
import ready from '../mastodon/ready';
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,6 @@ import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
import { start } from '../mastodon/common';
|
import { start } from '../mastodon/common';
|
||||||
import ComposeContainer from '../mastodon/containers/compose_container';
|
import ComposeContainer from '../mastodon/containers/compose_container';
|
||||||
import { loadLocale } from '../mastodon/load_locale';
|
|
||||||
import { loadPolyfills } from '../mastodon/polyfills';
|
import { loadPolyfills } from '../mastodon/polyfills';
|
||||||
import ready from '../mastodon/ready';
|
import ready from '../mastodon/ready';
|
||||||
|
|
||||||
|
@ -26,6 +25,6 @@ function main() {
|
||||||
ready(loaded);
|
ready(loaded);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadPolyfills().then(loadLocale).then(main).catch(error => {
|
loadPolyfills().then(main).catch(error => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
});
|
});
|
||||||
|
|
|
@ -13,7 +13,6 @@ const config = {
|
||||||
collectCoverageFrom: [
|
collectCoverageFrom: [
|
||||||
'app/javascript/mastodon/**/*.{js,jsx,ts,tsx}',
|
'app/javascript/mastodon/**/*.{js,jsx,ts,tsx}',
|
||||||
'!app/javascript/mastodon/features/emoji/emoji_compressed.js',
|
'!app/javascript/mastodon/features/emoji/emoji_compressed.js',
|
||||||
'!app/javascript/mastodon/locales/locale-data/*.js',
|
|
||||||
'!app/javascript/mastodon/service_worker/entry.js',
|
'!app/javascript/mastodon/service_worker/entry.js',
|
||||||
'!app/javascript/mastodon/test_setup.js',
|
'!app/javascript/mastodon/test_setup.js',
|
||||||
],
|
],
|
||||||
|
|
|
@ -49,6 +49,7 @@
|
||||||
"@reduxjs/toolkit": "^1.9.5",
|
"@reduxjs/toolkit": "^1.9.5",
|
||||||
"abortcontroller-polyfill": "^1.7.5",
|
"abortcontroller-polyfill": "^1.7.5",
|
||||||
"arrow-key-navigation": "^1.2.0",
|
"arrow-key-navigation": "^1.2.0",
|
||||||
|
"async-mutex": "^0.4.0",
|
||||||
"autoprefixer": "^10.4.14",
|
"autoprefixer": "^10.4.14",
|
||||||
"axios": "^1.4.0",
|
"axios": "^1.4.0",
|
||||||
"babel-loader": "^8.3.0",
|
"babel-loader": "^8.3.0",
|
||||||
|
|
|
@ -33,7 +33,7 @@ describe ReactComponentHelper do
|
||||||
|
|
||||||
it 'returns a tag with data attributes' do
|
it 'returns a tag with data attributes' do
|
||||||
expect(parsed_html.div['data-admin-component']).to eq('Name')
|
expect(parsed_html.div['data-admin-component']).to eq('Name')
|
||||||
expect(parsed_html.div['data-props']).to eq('{"locale":"en","one":"two"}')
|
expect(parsed_html.div['data-props']).to eq('{"one":"two"}')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -3273,6 +3273,13 @@ async-limiter@~1.0.0:
|
||||||
resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd"
|
resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd"
|
||||||
integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==
|
integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==
|
||||||
|
|
||||||
|
async-mutex@^0.4.0:
|
||||||
|
version "0.4.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/async-mutex/-/async-mutex-0.4.0.tgz#ae8048cd4d04ace94347507504b3cf15e631c25f"
|
||||||
|
integrity sha512-eJFZ1YhRR8UN8eBLoNzcDPcy/jqjsg6I1AP+KvWQX80BqOSW1oJPJXDylPUEeMr2ZQvHgnQ//Lp6f3RQ1zI7HA==
|
||||||
|
dependencies:
|
||||||
|
tslib "^2.4.0"
|
||||||
|
|
||||||
async@^2.6.2:
|
async@^2.6.2:
|
||||||
version "2.6.4"
|
version "2.6.4"
|
||||||
resolved "https://registry.yarnpkg.com/async/-/async-2.6.4.tgz#706b7ff6084664cd7eae713f6f965433b5504221"
|
resolved "https://registry.yarnpkg.com/async/-/async-2.6.4.tgz#706b7ff6084664cd7eae713f6f965433b5504221"
|
||||||
|
|
Loading…
Reference in New Issue