Add language picker to server rules section (#34820)

This commit is contained in:
Echo 2025-05-27 15:57:34 +02:00 committed by GitHub
parent 7ede5460d8
commit d78535eab9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 260 additions and 63 deletions

View file

@ -18,7 +18,7 @@ export interface SelectItem {
icon?: string;
iconComponent?: IconProp;
text: string;
meta: string;
meta?: string;
extra?: string;
}

View 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);
},
);

View file

@ -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>
);
};

View file

@ -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') ? (

View file

@ -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",

View file

@ -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;
}
}

View file

@ -9848,6 +9848,8 @@ noscript {
border: 1px solid var(--background-border-color);
color: $highlight-text-color;
cursor: pointer;
width: 100%;
background: none;
}
&.active &__title {