From d78535eab9cefc285c0d7ef88adb125ab1ceb6bd Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 27 May 2025 15:57:34 +0200 Subject: [PATCH] Add language picker to server rules section (#34820) --- .../mastodon/components/dropdown_selector.tsx | 2 +- .../features/about/components/rules.tsx | 156 ++++++++++++++++++ .../features/about/components/section.tsx | 45 +++++ .../mastodon/features/about/index.jsx | 66 +------- app/javascript/mastodon/locales/en.json | 2 + app/javascript/styles/mastodon/about.scss | 50 ++++++ .../styles/mastodon/components.scss | 2 + 7 files changed, 260 insertions(+), 63 deletions(-) create mode 100644 app/javascript/mastodon/features/about/components/rules.tsx create mode 100644 app/javascript/mastodon/features/about/components/section.tsx diff --git a/app/javascript/mastodon/components/dropdown_selector.tsx b/app/javascript/mastodon/components/dropdown_selector.tsx index b86d2d0f80..99bbd182e5 100644 --- a/app/javascript/mastodon/components/dropdown_selector.tsx +++ b/app/javascript/mastodon/components/dropdown_selector.tsx @@ -18,7 +18,7 @@ export interface SelectItem { icon?: string; iconComponent?: IconProp; text: string; - meta: string; + meta?: string; extra?: string; } diff --git a/app/javascript/mastodon/features/about/components/rules.tsx b/app/javascript/mastodon/features/about/components/rules.tsx new file mode 100644 index 0000000000..063b6b48ec --- /dev/null +++ b/app/javascript/mastodon/features/about/components/rules.tsx @@ -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; +} + +export const RulesSection: FC = ({ 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 = useCallback( + (e) => { + setLocale(e.currentTarget.value); + }, + [], + ); + + if (isLoading) { + return
; + } + + if (rules.length === 0) { + return ( +
+

+ +

+
+ ); + } + + return ( +
+
    + {rules.map((rule) => ( +
  1. +
    {rule.text}
    + {!!rule.hint &&
    {rule.hint}
    } +
  2. + ))} +
+ +
+ + +
+
+ ); +}; + +const selectRules = (state: RootState) => { + const rules = state.server.getIn([ + 'server', + 'rules', + ]) as ImmutableList | 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 = { + 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); + }, +); diff --git a/app/javascript/mastodon/features/about/components/section.tsx b/app/javascript/mastodon/features/about/components/section.tsx new file mode 100644 index 0000000000..e0ee076a5b --- /dev/null +++ b/app/javascript/mastodon/features/about/components/section.tsx @@ -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 = ({ + title, + children, + open = false, + onOpen, +}) => { + const [collapsed, setCollapsed] = useState(!open); + const handleClick: MouseEventHandler = useCallback(() => { + setCollapsed((prev) => !prev); + onOpen?.(); + }, [onOpen]); + return ( +
+ + + {!collapsed &&
{children}
} +
+ ); +}; diff --git a/app/javascript/mastodon/features/about/index.jsx b/app/javascript/mastodon/features/about/index.jsx index d2e1ea8d77..27f03b17cb 100644 --- a/app/javascript/mastodon/features/about/index.jsx +++ b/app/javascript/mastodon/features/about/index.jsx @@ -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 ( -
-
- {title} -
- - {!collapsed && ( -
{children}
- )} -
- ); - } - -} - class About extends PureComponent { static propTypes = { @@ -165,23 +123,7 @@ class About extends PureComponent { ))}
-
- {!isLoading && (server.get('rules', ImmutableList()).isEmpty() ? ( -

- ) : ( -
    - {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 ( -
  1. -
    {text}
    - {hint.length > 0 && (
    {hint}
    )} -
  2. - )})} -
- ))} -
+
{domainBlocks.get('isLoading') ? ( diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 91690a13fc..7b8c93382a 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -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", diff --git a/app/javascript/styles/mastodon/about.scss b/app/javascript/styles/mastodon/about.scss index 9a13034a3a..ba0605b79e 100644 --- a/app/javascript/styles/mastodon/about.scss +++ b/app/javascript/styles/mastodon/about.scss @@ -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,") + no-repeat 50% 50%; + mask-size: contain; + right: 8px; + background-color: lighten($ui-base-color, 12%); + pointer-events: none; + } +} diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index a8ca5fc3fd..644e6cd831 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -9848,6 +9848,8 @@ noscript { border: 1px solid var(--background-border-color); color: $highlight-text-color; cursor: pointer; + width: 100%; + background: none; } &.active &__title {