Add language picker to server rules section (#34820)
This commit is contained in:
parent
7ede5460d8
commit
d78535eab9
7 changed files with 260 additions and 63 deletions
|
@ -18,7 +18,7 @@ export interface SelectItem {
|
|||
icon?: string;
|
||||
iconComponent?: IconProp;
|
||||
text: string;
|
||||
meta: string;
|
||||
meta?: string;
|
||||
extra?: string;
|
||||
}
|
||||
|
||||
|
|
156
app/javascript/mastodon/features/about/components/rules.tsx
Normal file
156
app/javascript/mastodon/features/about/components/rules.tsx
Normal file
|
@ -0,0 +1,156 @@
|
|||
import { useCallback, useState } from 'react';
|
||||
import type { ChangeEventHandler, FC } from 'react';
|
||||
|
||||
import type { IntlShape } from 'react-intl';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import type { List as ImmutableList } from 'immutable';
|
||||
|
||||
import type { SelectItem } from '@/mastodon/components/dropdown_selector';
|
||||
import type { RootState } from '@/mastodon/store';
|
||||
import { useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { Section } from './section';
|
||||
|
||||
const messages = defineMessages({
|
||||
rules: { id: 'about.rules', defaultMessage: 'Server rules' },
|
||||
defaultLocale: { id: 'about.default_locale', defaultMessage: 'Default' },
|
||||
});
|
||||
|
||||
interface RulesSectionProps {
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
interface BaseRule {
|
||||
text: string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
interface Rule extends BaseRule {
|
||||
id: string;
|
||||
translations: Record<string, BaseRule>;
|
||||
}
|
||||
|
||||
export const RulesSection: FC<RulesSectionProps> = ({ isLoading = false }) => {
|
||||
const intl = useIntl();
|
||||
const [locale, setLocale] = useState(intl.locale);
|
||||
const rules = useAppSelector((state) => rulesSelector(state, locale));
|
||||
const localeOptions = useAppSelector((state) =>
|
||||
localeOptionsSelector(state, intl),
|
||||
);
|
||||
const handleLocaleChange: ChangeEventHandler<HTMLSelectElement> = useCallback(
|
||||
(e) => {
|
||||
setLocale(e.currentTarget.value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <Section title={intl.formatMessage(messages.rules)} />;
|
||||
}
|
||||
|
||||
if (rules.length === 0) {
|
||||
return (
|
||||
<Section title={intl.formatMessage(messages.rules)}>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='about.not_available'
|
||||
defaultMessage='This information has not been made available on this server.'
|
||||
/>
|
||||
</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={intl.formatMessage(messages.rules)}>
|
||||
<ol className='rules-list'>
|
||||
{rules.map((rule) => (
|
||||
<li key={rule.id}>
|
||||
<div className='rules-list__text'>{rule.text}</div>
|
||||
{!!rule.hint && <div className='rules-list__hint'>{rule.hint}</div>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<div className='rules-languages'>
|
||||
<label htmlFor='language-select'>
|
||||
<FormattedMessage
|
||||
id='about.language_label'
|
||||
defaultMessage='Language'
|
||||
/>
|
||||
</label>
|
||||
<select onChange={handleLocaleChange} id='language-select'>
|
||||
{localeOptions.map((option) => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
selected={option.value === locale}
|
||||
>
|
||||
{option.text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const selectRules = (state: RootState) => {
|
||||
const rules = state.server.getIn([
|
||||
'server',
|
||||
'rules',
|
||||
]) as ImmutableList<Rule> | null;
|
||||
if (!rules) {
|
||||
return [];
|
||||
}
|
||||
return rules.toJS() as Rule[];
|
||||
};
|
||||
|
||||
const rulesSelector = createSelector(
|
||||
[selectRules, (_state, locale: string) => locale],
|
||||
(rules, locale): Rule[] => {
|
||||
return rules.map((rule) => {
|
||||
const translations = rule.translations;
|
||||
if (translations[locale]) {
|
||||
rule.text = translations[locale].text;
|
||||
rule.hint = translations[locale].hint;
|
||||
}
|
||||
const partialLocale = locale.split('-')[0];
|
||||
if (partialLocale && translations[partialLocale]) {
|
||||
rule.text = translations[partialLocale].text;
|
||||
rule.hint = translations[partialLocale].hint;
|
||||
}
|
||||
return rule;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const localeOptionsSelector = createSelector(
|
||||
[selectRules, (_state, intl: IntlShape) => intl],
|
||||
(rules, intl): SelectItem[] => {
|
||||
const langs: Record<string, SelectItem> = {
|
||||
default: {
|
||||
value: 'default',
|
||||
text: intl.formatMessage(messages.defaultLocale),
|
||||
},
|
||||
};
|
||||
// Use the default locale as a target to translate language names.
|
||||
const intlLocale = new Intl.DisplayNames(intl.locale, {
|
||||
type: 'language',
|
||||
});
|
||||
for (const { translations } of rules) {
|
||||
for (const locale in translations) {
|
||||
if (langs[locale]) {
|
||||
continue; // Skip if already added
|
||||
}
|
||||
langs[locale] = {
|
||||
value: locale,
|
||||
text: intlLocale.of(locale) ?? locale,
|
||||
};
|
||||
}
|
||||
}
|
||||
return Object.values(langs);
|
||||
},
|
||||
);
|
|
@ -0,0 +1,45 @@
|
|||
import type { FC, MouseEventHandler } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Icon } from '@/mastodon/components/icon';
|
||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||
import ExpandMoreIcon from '@/material-icons/400-24px/expand_more.svg?react';
|
||||
|
||||
interface SectionProps {
|
||||
title: string;
|
||||
children?: React.ReactNode;
|
||||
open?: boolean;
|
||||
onOpen?: () => void;
|
||||
}
|
||||
|
||||
export const Section: FC<SectionProps> = ({
|
||||
title,
|
||||
children,
|
||||
open = false,
|
||||
onOpen,
|
||||
}) => {
|
||||
const [collapsed, setCollapsed] = useState(!open);
|
||||
const handleClick: MouseEventHandler = useCallback(() => {
|
||||
setCollapsed((prev) => !prev);
|
||||
onOpen?.();
|
||||
}, [onOpen]);
|
||||
return (
|
||||
<div className={classNames('about__section', { active: !collapsed })}>
|
||||
<button
|
||||
className='about__section__title'
|
||||
tabIndex={0}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Icon
|
||||
id={collapsed ? 'chevron-right' : 'chevron-down'}
|
||||
icon={collapsed ? ChevronRightIcon : ExpandMoreIcon}
|
||||
/>{' '}
|
||||
{title}
|
||||
</button>
|
||||
|
||||
{!collapsed && <div className='about__section__body'>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -3,26 +3,23 @@ import { PureComponent } from 'react';
|
|||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||
import ExpandMoreIcon from '@/material-icons/400-24px/expand_more.svg?react';
|
||||
import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'mastodon/actions/server';
|
||||
import { Account } from 'mastodon/components/account';
|
||||
import Column from 'mastodon/components/column';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { ServerHeroImage } from 'mastodon/components/server_hero_image';
|
||||
import { Skeleton } from 'mastodon/components/skeleton';
|
||||
import { LinkFooter} from 'mastodon/features/ui/components/link_footer';
|
||||
|
||||
import { Section } from './components/section';
|
||||
import { RulesSection } from './components/rules';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.about', defaultMessage: 'About' },
|
||||
rules: { id: 'about.rules', defaultMessage: 'Server rules' },
|
||||
blocks: { id: 'about.blocks', defaultMessage: 'Moderated servers' },
|
||||
silenced: { id: 'about.domain_blocks.silenced.title', defaultMessage: 'Limited' },
|
||||
silencedExplanation: { id: 'about.domain_blocks.silenced.explanation', defaultMessage: 'You will generally not see profiles and content from this server, unless you explicitly look it up or opt into it by following.' },
|
||||
|
@ -49,45 +46,6 @@ const mapStateToProps = state => ({
|
|||
domainBlocks: state.getIn(['server', 'domainBlocks']),
|
||||
});
|
||||
|
||||
class Section extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
title: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
open: PropTypes.bool,
|
||||
onOpen: PropTypes.func,
|
||||
};
|
||||
|
||||
state = {
|
||||
collapsed: !this.props.open,
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
const { onOpen } = this.props;
|
||||
const { collapsed } = this.state;
|
||||
|
||||
this.setState({ collapsed: !collapsed }, () => onOpen && onOpen());
|
||||
};
|
||||
|
||||
render () {
|
||||
const { title, children } = this.props;
|
||||
const { collapsed } = this.state;
|
||||
|
||||
return (
|
||||
<div className={classNames('about__section', { active: !collapsed })}>
|
||||
<div className='about__section__title' role='button' tabIndex={0} onClick={this.handleClick}>
|
||||
<Icon id={collapsed ? 'chevron-right' : 'chevron-down'} icon={collapsed ? ChevronRightIcon : ExpandMoreIcon} /> {title}
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div className='about__section__body'>{children}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class About extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
|
@ -165,23 +123,7 @@ class About extends PureComponent {
|
|||
))}
|
||||
</Section>
|
||||
|
||||
<Section title={intl.formatMessage(messages.rules)}>
|
||||
{!isLoading && (server.get('rules', ImmutableList()).isEmpty() ? (
|
||||
<p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>
|
||||
) : (
|
||||
<ol className='rules-list'>
|
||||
{server.get('rules').map(rule => {
|
||||
const text = rule.getIn(['translations', locale, 'text']) || rule.getIn(['translations', locale.split('-')[0], 'text']) || rule.get('text');
|
||||
const hint = rule.getIn(['translations', locale, 'hint']) || rule.getIn(['translations', locale.split('-')[0], 'hint']) || rule.get('hint');
|
||||
return (
|
||||
<li key={rule.get('id')}>
|
||||
<div className='rules-list__text'>{text}</div>
|
||||
{hint.length > 0 && (<div className='rules-list__hint'>{hint}</div>)}
|
||||
</li>
|
||||
)})}
|
||||
</ol>
|
||||
))}
|
||||
</Section>
|
||||
<RulesSection />
|
||||
|
||||
<Section title={intl.formatMessage(messages.blocks)} onOpen={this.handleDomainBlocksOpen}>
|
||||
{domainBlocks.get('isLoading') ? (
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"about.blocks": "Moderated servers",
|
||||
"about.contact": "Contact:",
|
||||
"about.default_locale": "Default",
|
||||
"about.disclaimer": "Mastodon is free, open-source software, and a trademark of Mastodon gGmbH.",
|
||||
"about.domain_blocks.no_reason_available": "Reason not available",
|
||||
"about.domain_blocks.preamble": "Mastodon generally allows you to view content from and interact with users from any other server in the fediverse. These are the exceptions that have been made on this particular server.",
|
||||
|
@ -8,6 +9,7 @@
|
|||
"about.domain_blocks.silenced.title": "Limited",
|
||||
"about.domain_blocks.suspended.explanation": "No data from this server will be processed, stored or exchanged, making any interaction or communication with users from this server impossible.",
|
||||
"about.domain_blocks.suspended.title": "Suspended",
|
||||
"about.language_label": "Language",
|
||||
"about.not_available": "This information has not been made available on this server.",
|
||||
"about.powered_by": "Decentralized social media powered by {mastodon}",
|
||||
"about.rules": "Server rules",
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
@use 'variables' as *;
|
||||
@use 'functions' as *;
|
||||
|
||||
$maximum-width: 1235px;
|
||||
$fluid-breakpoint: $maximum-width + 20px;
|
||||
|
@ -93,3 +94,52 @@ $fluid-breakpoint: $maximum-width + 20px;
|
|||
color: $darker-text-color;
|
||||
}
|
||||
}
|
||||
|
||||
.rules-languages {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
|
||||
> label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $primary-text-color;
|
||||
}
|
||||
|
||||
> select {
|
||||
appearance: none;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
color: $primary-text-color;
|
||||
display: block;
|
||||
width: 100%;
|
||||
outline: 0;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
background: $ui-base-color;
|
||||
border: 1px solid var(--background-border-color);
|
||||
border-radius: 4px;
|
||||
padding-inline-start: 10px;
|
||||
padding-inline-end: 30px;
|
||||
height: 41px;
|
||||
|
||||
@media screen and (width <= 600px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
content: '';
|
||||
mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='14.933' height='18.467' viewBox='0 0 14.933 18.467'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='currentColor' /></svg>")
|
||||
no-repeat 50% 50%;
|
||||
mask-size: contain;
|
||||
right: 8px;
|
||||
background-color: lighten($ui-base-color, 12%);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9848,6 +9848,8 @@ noscript {
|
|||
border: 1px solid var(--background-border-color);
|
||||
color: $highlight-text-color;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
background: none;
|
||||
}
|
||||
|
||||
&.active &__title {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue