From cedcece0ccba626d97a910f2e3fecf93c2729ca4 Mon Sep 17 00:00:00 2001 From: Claire Date: Wed, 5 Oct 2022 00:16:40 +0200 Subject: Fix deleted pinned posts potentially counting towards the pinned posts limit (#19005) Fixes #18938 --- app/controllers/api/v1/statuses_controller.rb | 1 + 1 file changed, 1 insertion(+) (limited to 'app/controllers/api/v1') diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 9270117da..1cc5aa32e 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -77,6 +77,7 @@ class Api::V1::StatusesController < Api::BaseController authorize @status, :destroy? @status.discard + StatusPin.find_by(status: @status)&.destroy @status.account.statuses_count = @status.account.statuses_count - 1 json = render_to_body json: @status, serializer: REST::StatusSerializer, source_requested: true -- cgit From d2528b26b6da34f34b5d7a392e263428d3c09d69 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 5 Oct 2022 03:47:56 +0200 Subject: Add server banner to web app, add `GET /api/v2/instance` to REST API (#19294) --- app/controllers/about_controller.rb | 2 +- app/controllers/api/v1/instances_controller.rb | 2 +- app/controllers/api/v2/instances_controller.rb | 8 ++ app/javascript/mastodon/actions/rules.js | 27 ------ app/javascript/mastodon/actions/server.js | 30 ++++++ app/javascript/mastodon/components/account.js | 14 ++- app/javascript/mastodon/components/display_name.js | 8 +- .../mastodon/components/server_banner.js | 91 ++++++++++++++++++ app/javascript/mastodon/features/report/rules.js | 2 +- .../features/ui/components/compose_panel.js | 2 + .../features/ui/components/report_modal.js | 4 +- app/javascript/mastodon/features/ui/index.js | 4 +- app/javascript/mastodon/initial_state.js | 1 - app/javascript/mastodon/reducers/index.js | 4 +- app/javascript/mastodon/reducers/rules.js | 13 --- app/javascript/mastodon/reducers/server.js | 19 ++++ app/javascript/styles/mastodon/components.scss | 82 +++++++++++++++++ app/presenters/instance_presenter.rb | 64 +++++++++---- app/serializers/initial_state_serializer.rb | 19 ++-- app/serializers/manifest_serializer.rb | 4 +- app/serializers/rest/instance_serializer.rb | 81 +++++----------- app/serializers/rest/v1/instance_serializer.rb | 102 +++++++++++++++++++++ app/views/about/more.html.haml | 8 +- app/views/about/show.html.haml | 6 +- app/views/application/_sidebar.html.haml | 4 +- app/views/privacy/show.html.haml | 2 +- app/views/shared/_og.html.haml | 4 +- config/routes.rb | 4 +- spec/presenters/instance_presenter_spec.rb | 38 ++++---- spec/views/about/show.html.haml_spec.rb | 20 +--- 30 files changed, 473 insertions(+), 196 deletions(-) create mode 100644 app/controllers/api/v2/instances_controller.rb delete mode 100644 app/javascript/mastodon/actions/rules.js create mode 100644 app/javascript/mastodon/actions/server.js create mode 100644 app/javascript/mastodon/components/server_banner.js delete mode 100644 app/javascript/mastodon/reducers/rules.js create mode 100644 app/javascript/mastodon/reducers/server.js create mode 100644 app/serializers/rest/v1/instance_serializer.rb (limited to 'app/controllers/api/v1') diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb index 4fc2fbe34..8da4a19ab 100644 --- a/app/controllers/about_controller.rb +++ b/app/controllers/about_controller.rb @@ -18,7 +18,7 @@ class AboutController < ApplicationController def more flash.now[:notice] = I18n.t('about.instance_actor_flash') if params[:instance_actor] - toc_generator = TOCGenerator.new(@instance_presenter.site_extended_description) + toc_generator = TOCGenerator.new(@instance_presenter.extended_description) @rules = Rule.ordered @contents = toc_generator.html diff --git a/app/controllers/api/v1/instances_controller.rb b/app/controllers/api/v1/instances_controller.rb index 5b5058a7b..913319a86 100644 --- a/app/controllers/api/v1/instances_controller.rb +++ b/app/controllers/api/v1/instances_controller.rb @@ -6,6 +6,6 @@ class Api::V1::InstancesController < Api::BaseController def show expires_in 3.minutes, public: true - render_with_cache json: {}, serializer: REST::InstanceSerializer, root: 'instance' + render_with_cache json: InstancePresenter.new, serializer: REST::V1::InstanceSerializer, root: 'instance' end end diff --git a/app/controllers/api/v2/instances_controller.rb b/app/controllers/api/v2/instances_controller.rb new file mode 100644 index 000000000..bcd90cff2 --- /dev/null +++ b/app/controllers/api/v2/instances_controller.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class Api::V2::InstancesController < Api::V1::InstancesController + def show + expires_in 3.minutes, public: true + render_with_cache json: InstancePresenter.new, serializer: REST::InstanceSerializer, root: 'instance' + end +end diff --git a/app/javascript/mastodon/actions/rules.js b/app/javascript/mastodon/actions/rules.js deleted file mode 100644 index 34e60a121..000000000 --- a/app/javascript/mastodon/actions/rules.js +++ /dev/null @@ -1,27 +0,0 @@ -import api from '../api'; - -export const RULES_FETCH_REQUEST = 'RULES_FETCH_REQUEST'; -export const RULES_FETCH_SUCCESS = 'RULES_FETCH_SUCCESS'; -export const RULES_FETCH_FAIL = 'RULES_FETCH_FAIL'; - -export const fetchRules = () => (dispatch, getState) => { - dispatch(fetchRulesRequest()); - - api(getState) - .get('/api/v1/instance').then(({ data }) => dispatch(fetchRulesSuccess(data.rules))) - .catch(err => dispatch(fetchRulesFail(err))); -}; - -const fetchRulesRequest = () => ({ - type: RULES_FETCH_REQUEST, -}); - -const fetchRulesSuccess = rules => ({ - type: RULES_FETCH_SUCCESS, - rules, -}); - -const fetchRulesFail = error => ({ - type: RULES_FETCH_FAIL, - error, -}); diff --git a/app/javascript/mastodon/actions/server.js b/app/javascript/mastodon/actions/server.js new file mode 100644 index 000000000..af8fef780 --- /dev/null +++ b/app/javascript/mastodon/actions/server.js @@ -0,0 +1,30 @@ +import api from '../api'; +import { importFetchedAccount } from './importer'; + +export const SERVER_FETCH_REQUEST = 'Server_FETCH_REQUEST'; +export const SERVER_FETCH_SUCCESS = 'Server_FETCH_SUCCESS'; +export const SERVER_FETCH_FAIL = 'Server_FETCH_FAIL'; + +export const fetchServer = () => (dispatch, getState) => { + dispatch(fetchServerRequest()); + + api(getState) + .get('/api/v2/instance').then(({ data }) => { + if (data.contact.account) dispatch(importFetchedAccount(data.contact.account)); + dispatch(fetchServerSuccess(data)); + }).catch(err => dispatch(fetchServerFail(err))); +}; + +const fetchServerRequest = () => ({ + type: SERVER_FETCH_REQUEST, +}); + +const fetchServerSuccess = server => ({ + type: SERVER_FETCH_SUCCESS, + server, +}); + +const fetchServerFail = error => ({ + type: SERVER_FETCH_FAIL, + error, +}); diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js index af9f119c8..36429e647 100644 --- a/app/javascript/mastodon/components/account.js +++ b/app/javascript/mastodon/components/account.js @@ -9,6 +9,7 @@ import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { me } from '../initial_state'; import RelativeTimestamp from './relative_timestamp'; +import Skeleton from 'mastodon/components/skeleton'; const messages = defineMessages({ follow: { id: 'account.follow', defaultMessage: 'Follow' }, @@ -26,7 +27,7 @@ export default @injectIntl class Account extends ImmutablePureComponent { static propTypes = { - account: ImmutablePropTypes.map.isRequired, + account: ImmutablePropTypes.map, onFollow: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired, onMute: PropTypes.func.isRequired, @@ -67,7 +68,16 @@ class Account extends ImmutablePureComponent { const { account, intl, hidden, onActionClick, actionIcon, actionTitle, defaultAction } = this.props; if (!account) { - return
; + return ( +
+
+
+
+ +
+
+
+ ); } if (hidden) { diff --git a/app/javascript/mastodon/components/display_name.js b/app/javascript/mastodon/components/display_name.js index 7ccfbd0cd..e9139ab0f 100644 --- a/app/javascript/mastodon/components/display_name.js +++ b/app/javascript/mastodon/components/display_name.js @@ -2,11 +2,12 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import { autoPlayGif } from 'mastodon/initial_state'; +import Skeleton from 'mastodon/components/skeleton'; export default class DisplayName extends React.PureComponent { static propTypes = { - account: ImmutablePropTypes.map.isRequired, + account: ImmutablePropTypes.map, others: ImmutablePropTypes.list, localDomain: PropTypes.string, }; @@ -48,7 +49,7 @@ export default class DisplayName extends React.PureComponent { if (others.size - 2 > 0) { suffix = `+${others.size - 2}`; } - } else { + } else if ((others && others.size > 0) || this.props.account) { if (others && others.size > 0) { account = others.first(); } else { @@ -63,6 +64,9 @@ export default class DisplayName extends React.PureComponent { displayName = ; suffix = @{acct}; + } else { + displayName = ; + suffix = ; } return ( diff --git a/app/javascript/mastodon/components/server_banner.js b/app/javascript/mastodon/components/server_banner.js new file mode 100644 index 000000000..bdd7f7380 --- /dev/null +++ b/app/javascript/mastodon/components/server_banner.js @@ -0,0 +1,91 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { domain } from 'mastodon/initial_state'; +import { fetchServer } from 'mastodon/actions/server'; +import { connect } from 'react-redux'; +import Account from 'mastodon/containers/account_container'; +import ShortNumber from 'mastodon/components/short_number'; +import Skeleton from 'mastodon/components/skeleton'; +import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; + +const messages = defineMessages({ + aboutActiveUsers: { id: 'server_banner.about_active_users', defaultMessage: 'People using this server during the last 30 days (Monthly Active Users)' }, +}); + +const mapStateToProps = state => ({ + server: state.get('server'), +}); + +export default @connect(mapStateToProps) +@injectIntl +class ServerBanner extends React.PureComponent { + + static propTypes = { + server: PropTypes.object, + dispatch: PropTypes.func, + intl: PropTypes.object, + }; + + componentDidMount () { + const { dispatch } = this.props; + dispatch(fetchServer()); + } + + render () { + const { server, intl } = this.props; + const isLoading = server.get('isLoading'); + + return ( +
+
+ {domain}, mastodon: Mastodon }} /> +
+ + {server.get('title')} + +
+ {isLoading ? ( + <> + +
+ +
+ + + ) : server.get('description')} +
+ +
+
+

+ + +
+ +
+

+ + {isLoading ? ( + <> + +
+ + + ) : ( + <> + +
+ + + )} +
+
+ +
+ + +
+ ); + } + +} diff --git a/app/javascript/mastodon/features/report/rules.js b/app/javascript/mastodon/features/report/rules.js index f2db0d9e4..2cb4a95b5 100644 --- a/app/javascript/mastodon/features/report/rules.js +++ b/app/javascript/mastodon/features/report/rules.js @@ -7,7 +7,7 @@ import Button from 'mastodon/components/button'; import Option from './components/option'; const mapStateToProps = state => ({ - rules: state.get('rules'), + rules: state.getIn(['server', 'rules']), }); export default @connect(mapStateToProps) diff --git a/app/javascript/mastodon/features/ui/components/compose_panel.js b/app/javascript/mastodon/features/ui/components/compose_panel.js index 655c202fa..1d481eab5 100644 --- a/app/javascript/mastodon/features/ui/components/compose_panel.js +++ b/app/javascript/mastodon/features/ui/components/compose_panel.js @@ -5,6 +5,7 @@ import SearchContainer from 'mastodon/features/compose/containers/search_contain import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container'; import NavigationContainer from 'mastodon/features/compose/containers/navigation_container'; import LinkFooter from './link_footer'; +import ServerBanner from 'mastodon/components/server_banner'; import { changeComposing } from 'mastodon/actions/compose'; export default @connect() @@ -35,6 +36,7 @@ class ComposePanel extends React.PureComponent { {!signedIn && ( +
)} diff --git a/app/javascript/mastodon/features/ui/components/report_modal.js b/app/javascript/mastodon/features/ui/components/report_modal.js index 744dd248b..264da07ce 100644 --- a/app/javascript/mastodon/features/ui/components/report_modal.js +++ b/app/javascript/mastodon/features/ui/components/report_modal.js @@ -2,7 +2,7 @@ import React from 'react'; import { connect } from 'react-redux'; import { submitReport } from 'mastodon/actions/reports'; import { expandAccountTimeline } from 'mastodon/actions/timelines'; -import { fetchRules } from 'mastodon/actions/rules'; +import { fetchServer } from 'mastodon/actions/server'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { makeGetAccount } from 'mastodon/selectors'; @@ -117,7 +117,7 @@ class ReportModal extends ImmutablePureComponent { const { dispatch, accountId } = this.props; dispatch(expandAccountTimeline(accountId, { withReplies: true })); - dispatch(fetchRules()); + dispatch(fetchServer()); } render () { diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 4e37908c8..bc6ff1866 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -13,7 +13,7 @@ import { debounce } from 'lodash'; import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose'; import { expandHomeTimeline } from '../../actions/timelines'; import { expandNotifications } from '../../actions/notifications'; -import { fetchRules } from '../../actions/rules'; +import { fetchServer } from '../../actions/server'; import { clearHeight } from '../../actions/height_cache'; import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app'; import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers'; @@ -392,7 +392,7 @@ class UI extends React.PureComponent { this.props.dispatch(expandHomeTimeline()); this.props.dispatch(expandNotifications()); - setTimeout(() => this.props.dispatch(fetchRules()), 3000); + setTimeout(() => this.props.dispatch(fetchServer()), 3000); } this.hotkeys.__mousetrap__.stopCallback = (e, element) => { diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js index 81607a03b..9ecbfe576 100644 --- a/app/javascript/mastodon/initial_state.js +++ b/app/javascript/mastodon/initial_state.js @@ -28,6 +28,5 @@ export const title = getMeta('title'); export const cropImages = getMeta('crop_images'); export const disableSwiping = getMeta('disable_swiping'); export const languages = initialState && initialState.languages; -export const server = initialState && initialState.server; export default initialState; diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js index d3d0303df..bccdc1865 100644 --- a/app/javascript/mastodon/reducers/index.js +++ b/app/javascript/mastodon/reducers/index.js @@ -17,7 +17,7 @@ import status_lists from './status_lists'; import mutes from './mutes'; import blocks from './blocks'; import boosts from './boosts'; -import rules from './rules'; +import server from './server'; import contexts from './contexts'; import compose from './compose'; import search from './search'; @@ -62,7 +62,7 @@ const reducers = { mutes, blocks, boosts, - rules, + server, contexts, compose, search, diff --git a/app/javascript/mastodon/reducers/rules.js b/app/javascript/mastodon/reducers/rules.js deleted file mode 100644 index c1180b520..000000000 --- a/app/javascript/mastodon/reducers/rules.js +++ /dev/null @@ -1,13 +0,0 @@ -import { RULES_FETCH_SUCCESS } from 'mastodon/actions/rules'; -import { List as ImmutableList, fromJS } from 'immutable'; - -const initialState = ImmutableList(); - -export default function rules(state = initialState, action) { - switch (action.type) { - case RULES_FETCH_SUCCESS: - return fromJS(action.rules); - default: - return state; - } -} diff --git a/app/javascript/mastodon/reducers/server.js b/app/javascript/mastodon/reducers/server.js new file mode 100644 index 000000000..68131c6dd --- /dev/null +++ b/app/javascript/mastodon/reducers/server.js @@ -0,0 +1,19 @@ +import { SERVER_FETCH_REQUEST, SERVER_FETCH_SUCCESS, SERVER_FETCH_FAIL } from 'mastodon/actions/server'; +import { Map as ImmutableMap, fromJS } from 'immutable'; + +const initialState = ImmutableMap({ + isLoading: true, +}); + +export default function server(state = initialState, action) { + switch (action.type) { + case SERVER_FETCH_REQUEST: + return state.set('isLoading', true); + case SERVER_FETCH_SUCCESS: + return fromJS(action.server).set('isLoading', false); + case SERVER_FETCH_FAIL: + return state.set('isLoading', false); + default: + return state; + } +} diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index e831fce53..4bdd5accf 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -8021,3 +8021,85 @@ noscript { } } } + +.server-banner { + padding: 20px 0; + + &__introduction { + color: $darker-text-color; + margin-bottom: 20px; + + strong { + font-weight: 600; + } + + a { + color: inherit; + text-decoration: underline; + + &:hover, + &:active, + &:focus { + text-decoration: none; + } + } + } + + &__hero { + display: block; + border-radius: 4px; + width: 100%; + height: auto; + margin-bottom: 20px; + aspect-ratio: 1.9; + border: 0; + background: $ui-base-color; + object-fit: cover; + } + + &__description { + margin-bottom: 20px; + } + + &__meta { + display: flex; + gap: 10px; + max-width: 100%; + + &__column { + flex: 0 0 auto; + width: calc(50% - 5px); + overflow: hidden; + } + } + + &__number { + font-weight: 600; + color: $primary-text-color; + } + + &__number-label { + color: $darker-text-color; + font-weight: 500; + } + + h4 { + text-transform: uppercase; + color: $darker-text-color; + margin-bottom: 10px; + font-weight: 600; + } + + .account { + padding: 0; + border: 0; + } + + .account__avatar-wrapper { + margin-left: 0; + } + + .spacer { + margin: 10px 0; + } +} diff --git a/app/presenters/instance_presenter.rb b/app/presenters/instance_presenter.rb index 3e85faa92..c461ac55f 100644 --- a/app/presenters/instance_presenter.rb +++ b/app/presenters/instance_presenter.rb @@ -1,19 +1,51 @@ # frozen_string_literal: true -class InstancePresenter - delegate( - :site_contact_email, - :site_title, - :site_short_description, - :site_description, - :site_extended_description, - :site_terms, - :closed_registrations_message, - to: Setting - ) - - def contact_account - Account.find_local(Setting.site_contact_username.strip.gsub(/\A@/, '')) +class InstancePresenter < ActiveModelSerializers::Model + attributes :domain, :title, :version, :source_url, + :description, :languages, :rules, :contact + + class ContactPresenter < ActiveModelSerializers::Model + attributes :email, :account + + def email + Setting.site_contact_email + end + + def account + Account.find_local(Setting.site_contact_username.strip.gsub(/\A@/, '')) + end + end + + def contact + ContactPresenter.new + end + + def closed_registrations_message + Setting.closed_registrations_message + end + + def description + Setting.site_short_description + end + + def extended_description + Setting.site_extended_description + end + + def privacy_policy + Setting.site_terms + end + + def domain + Rails.configuration.x.local_domain + end + + def title + Setting.site_title + end + + def languages + [I18n.default_locale] end def rules @@ -40,8 +72,8 @@ class InstancePresenter Rails.cache.fetch('sample_accounts', expires_in: 12.hours) { Account.local.discoverable.popular.limit(3) } end - def version_number - Mastodon::Version + def version + Mastodon::Version.to_s end def source_url diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index 87f4db83d..1a49182a8 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -5,23 +5,24 @@ class InitialStateSerializer < ActiveModel::Serializer attributes :meta, :compose, :accounts, :media_attachments, :settings, - :languages, :server + :languages has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer has_one :role, serializer: REST::RoleSerializer + # rubocop:disable Metrics/AbcSize def meta store = { streaming_api_base_url: Rails.configuration.x.streaming_api_base_url, access_token: object.token, locale: I18n.locale, - domain: Rails.configuration.x.local_domain, - title: instance_presenter.site_title, + domain: instance_presenter.domain, + title: instance_presenter.title, admin: object.admin&.id&.to_s, search_enabled: Chewy.enabled?, repository: Mastodon::Version.repository, - source_url: Mastodon::Version.source_url, - version: Mastodon::Version.to_s, + source_url: instance_presenter.source_url, + version: instance_presenter.version, limited_federation_mode: Rails.configuration.x.whitelist_mode, mascot: instance_presenter.mascot&.file&.url, profile_directory: Setting.profile_directory, @@ -54,6 +55,7 @@ class InitialStateSerializer < ActiveModel::Serializer store end + # rubocop:enable Metrics/AbcSize def compose store = {} @@ -85,13 +87,6 @@ class InitialStateSerializer < ActiveModel::Serializer LanguagesHelper::SUPPORTED_LOCALES.map { |(key, value)| [key, value[0], value[1]] } end - def server - { - hero: instance_presenter.hero&.file&.url || instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.png'), - description: instance_presenter.site_short_description.presence || I18n.t('about.about_mastodon_html'), - } - end - private def instance_presenter diff --git a/app/serializers/manifest_serializer.rb b/app/serializers/manifest_serializer.rb index 9827323a8..6b5296480 100644 --- a/app/serializers/manifest_serializer.rb +++ b/app/serializers/manifest_serializer.rb @@ -22,11 +22,11 @@ class ManifestSerializer < ActiveModel::Serializer :share_target, :shortcuts def name - object.site_title + object.title end def short_name - object.site_title + object.title end def icons diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb index 9cc245422..f4ea49427 100644 --- a/app/serializers/rest/instance_serializer.rb +++ b/app/serializers/rest/instance_serializer.rb @@ -1,61 +1,39 @@ # frozen_string_literal: true class REST::InstanceSerializer < ActiveModel::Serializer - include RoutingHelper - - attributes :uri, :title, :short_description, :description, :email, - :version, :urls, :stats, :thumbnail, - :languages, :registrations, :approval_required, :invites_enabled, - :configuration - - has_one :contact_account, serializer: REST::AccountSerializer - - has_many :rules, serializer: REST::RuleSerializer - - delegate :contact_account, :rules, to: :instance_presenter - - def uri - Rails.configuration.x.local_domain - end + class ContactSerializer < ActiveModel::Serializer + attributes :email - def title - Setting.site_title + has_one :account, serializer: REST::AccountSerializer end - def short_description - Setting.site_short_description - end - - def description - Setting.site_description - end + include RoutingHelper - def email - Setting.site_contact_email - end + attributes :domain, :title, :version, :source_url, :description, + :usage, :thumbnail, :languages, :configuration, + :registrations - def version - Mastodon::Version.to_s - end + has_one :contact, serializer: ContactSerializer + has_many :rules, serializer: REST::RuleSerializer def thumbnail - instance_presenter.thumbnail ? full_asset_url(instance_presenter.thumbnail.file.url) : full_pack_url('media/images/preview.png') + object.thumbnail ? full_asset_url(object.thumbnail.file.url) : full_pack_url('media/images/preview.png') end - def stats + def usage { - user_count: instance_presenter.user_count, - status_count: instance_presenter.status_count, - domain_count: instance_presenter.domain_count, + users: { + active_month: object.active_user_count(4), + }, } end - def urls - { streaming_api: Rails.configuration.x.streaming_api_base_url } - end - def configuration { + urls: { + streaming: Rails.configuration.x.streaming_api_base_url, + }, + statuses: { max_characters: StatusLengthValidator::MAX_CHARS, max_media_attachments: 4, @@ -80,25 +58,10 @@ class REST::InstanceSerializer < ActiveModel::Serializer } end - def languages - [I18n.default_locale] - end - def registrations - Setting.registrations_mode != 'none' && !Rails.configuration.x.single_user_mode - end - - def approval_required - Setting.registrations_mode == 'approved' - end - - def invites_enabled - UserRole.everyone.can?(:invite_users) - end - - private - - def instance_presenter - @instance_presenter ||= InstancePresenter.new + { + enabled: Setting.registrations_mode != 'none' && !Rails.configuration.x.single_user_mode, + approval_required: Setting.registrations_mode == 'approved', + } end end diff --git a/app/serializers/rest/v1/instance_serializer.rb b/app/serializers/rest/v1/instance_serializer.rb new file mode 100644 index 000000000..47fa086fc --- /dev/null +++ b/app/serializers/rest/v1/instance_serializer.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +class REST::V1::InstanceSerializer < ActiveModel::Serializer + include RoutingHelper + + attributes :uri, :title, :short_description, :description, :email, + :version, :urls, :stats, :thumbnail, + :languages, :registrations, :approval_required, :invites_enabled, + :configuration + + has_one :contact_account, serializer: REST::AccountSerializer + + has_many :rules, serializer: REST::RuleSerializer + + def uri + object.domain + end + + def short_description + object.description + end + + def description + Setting.site_description # Legacy + end + + def email + object.contact.email + end + + def contact_account + object.contact.account + end + + def thumbnail + instance_presenter.thumbnail ? full_asset_url(instance_presenter.thumbnail.file.url) : full_pack_url('media/images/preview.png') + end + + def stats + { + user_count: instance_presenter.user_count, + status_count: instance_presenter.status_count, + domain_count: instance_presenter.domain_count, + } + end + + def urls + { streaming_api: Rails.configuration.x.streaming_api_base_url } + end + + def usage + { + users: { + active_month: instance_presenter.active_user_count(4), + }, + } + end + + def configuration + { + statuses: { + max_characters: StatusLengthValidator::MAX_CHARS, + max_media_attachments: 4, + characters_reserved_per_url: StatusLengthValidator::URL_PLACEHOLDER_CHARS, + }, + + media_attachments: { + supported_mime_types: MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES + MediaAttachment::AUDIO_MIME_TYPES, + image_size_limit: MediaAttachment::IMAGE_LIMIT, + image_matrix_limit: Attachmentable::MAX_MATRIX_LIMIT, + video_size_limit: MediaAttachment::VIDEO_LIMIT, + video_frame_rate_limit: MediaAttachment::MAX_VIDEO_FRAME_RATE, + video_matrix_limit: MediaAttachment::MAX_VIDEO_MATRIX_LIMIT, + }, + + polls: { + max_options: PollValidator::MAX_OPTIONS, + max_characters_per_option: PollValidator::MAX_OPTION_CHARS, + min_expiration: PollValidator::MIN_EXPIRATION, + max_expiration: PollValidator::MAX_EXPIRATION, + }, + } + end + + def registrations + Setting.registrations_mode != 'none' && !Rails.configuration.x.single_user_mode + end + + def approval_required + Setting.registrations_mode == 'approved' + end + + def invites_enabled + UserRole.everyone.can?(:invite_users) + end + + private + + def instance_presenter + @instance_presenter ||= InstancePresenter.new + end +end diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml index 3b48afc0c..a5a10b620 100644 --- a/app/views/about/more.html.haml +++ b/app/views/about/more.html.haml @@ -9,7 +9,7 @@ .column-0 .public-account-header.public-account-header--no-bar .public-account-header__image - = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.png'), alt: @instance_presenter.site_title, class: 'parallax' + = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.png'), alt: @instance_presenter.title, class: 'parallax' .column-1 .landing-page__call-to-action{ dir: 'ltr' } @@ -31,14 +31,14 @@ .contact-widget %h4= t 'about.administered_by' - = account_link_to(@instance_presenter.contact_account) + = account_link_to(@instance_presenter.contact.account) - - if @instance_presenter.site_contact_email.present? + - if @instance_presenter.contact.email.present? %h4 = succeed ':' do = t 'about.contact' - = mail_to @instance_presenter.site_contact_email, nil, title: @instance_presenter.site_contact_email + = mail_to @instance_presenter.contact.email, nil, title: @instance_presenter.contact.email .column-3 = render 'application/flashes' diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml index d61b3cd51..75124d5e2 100644 --- a/app/views/about/show.html.haml +++ b/app/views/about/show.html.haml @@ -40,11 +40,11 @@ .hero-widget .hero-widget__img - = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.png'), alt: @instance_presenter.site_title + = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.png'), alt: @instance_presenter.title .hero-widget__text %p - = @instance_presenter.site_short_description.html_safe.presence || t('about.about_mastodon_html') + = @instance_presenter.description.html_safe.presence || t('about.about_mastodon_html') = link_to about_more_path do = t('about.learn_more') = fa_icon 'angle-double-right' @@ -53,7 +53,7 @@ .hero-widget__footer__column %h4= t 'about.administered_by' - = account_link_to @instance_presenter.contact_account + = account_link_to @instance_presenter.contact.account .hero-widget__footer__column %h4= t 'about.server_stats' diff --git a/app/views/application/_sidebar.html.haml b/app/views/application/_sidebar.html.haml index cc157bf47..eb2813dd0 100644 --- a/app/views/application/_sidebar.html.haml +++ b/app/views/application/_sidebar.html.haml @@ -1,9 +1,9 @@ .hero-widget .hero-widget__img - = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.png'), alt: @instance_presenter.site_title + = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.png'), alt: @instance_presenter.title .hero-widget__text - %p= @instance_presenter.site_short_description.html_safe.presence || t('about.about_mastodon_html') + %p= @instance_presenter.description.html_safe.presence || t('about.about_mastodon_html') - if Setting.trends && !(user_signed_in? && !current_user.setting_trends) - trends = Trends.tags.query.allowed.limit(3) diff --git a/app/views/privacy/show.html.haml b/app/views/privacy/show.html.haml index 9d076a91b..cdd38a595 100644 --- a/app/views/privacy/show.html.haml +++ b/app/views/privacy/show.html.haml @@ -4,6 +4,6 @@ .grid .column-0 .box-widget - .rich-formatting= @instance_presenter.site_terms.html_safe.presence || t('terms.body_html') + .rich-formatting= @instance_presenter.privacy_policy.html_safe.presence || t('terms.body_html') .column-1 = render 'application/sidebar' diff --git a/app/views/shared/_og.html.haml b/app/views/shared/_og.html.haml index 7feae1b8b..b54ab2429 100644 --- a/app/views/shared/_og.html.haml +++ b/app/views/shared/_og.html.haml @@ -1,12 +1,12 @@ - thumbnail = @instance_presenter.thumbnail -- description ||= strip_tags(@instance_presenter.site_short_description.presence || t('about.about_mastodon_html')) +- description ||= strip_tags(@instance_presenter.description.presence || t('about.about_mastodon_html')) %meta{ name: 'description', content: description }/ = opengraph 'og:site_name', t('about.hosted_on', domain: site_hostname) = opengraph 'og:url', url_for(only_path: false) = opengraph 'og:type', 'website' -= opengraph 'og:title', @instance_presenter.site_title += opengraph 'og:title', @instance_presenter.title = opengraph 'og:description', description = opengraph 'og:image', full_asset_url(thumbnail&.file&.url || asset_pack_path('media/images/preview.png', protocol: :request)) = opengraph 'og:image:width', thumbnail ? thumbnail.meta['width'] : '1200' diff --git a/config/routes.rb b/config/routes.rb index d2ede87d3..188898fd0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -616,10 +616,12 @@ Rails.application.routes.draw do end namespace :v2 do - resources :media, only: [:create] get '/search', to: 'search#index', as: :search + + resources :media, only: [:create] resources :suggestions, only: [:index] resources :filters, only: [:index, :create, :show, :update, :destroy] + resource :instance, only: [:show] namespace :admin do resources :accounts, only: [:index] diff --git a/spec/presenters/instance_presenter_spec.rb b/spec/presenters/instance_presenter_spec.rb index 973b3e23c..a0a8628e8 100644 --- a/spec/presenters/instance_presenter_spec.rb +++ b/spec/presenters/instance_presenter_spec.rb @@ -3,21 +3,20 @@ require 'rails_helper' describe InstancePresenter do let(:instance_presenter) { InstancePresenter.new } - context do + describe '#description' do around do |example| - site_description = Setting.site_description + site_description = Setting.site_short_description example.run - Setting.site_description = site_description + Setting.site_short_description = site_description end it "delegates site_description to Setting" do - Setting.site_description = "Site desc" - - expect(instance_presenter.site_description).to eq "Site desc" + Setting.site_short_description = "Site desc" + expect(instance_presenter.description).to eq "Site desc" end end - context do + describe '#extended_description' do around do |example| site_extended_description = Setting.site_extended_description example.run @@ -26,12 +25,11 @@ describe InstancePresenter do it "delegates site_extended_description to Setting" do Setting.site_extended_description = "Extended desc" - - expect(instance_presenter.site_extended_description).to eq "Extended desc" + expect(instance_presenter.extended_description).to eq "Extended desc" end end - context do + describe '#email' do around do |example| site_contact_email = Setting.site_contact_email example.run @@ -40,12 +38,11 @@ describe InstancePresenter do it "delegates contact_email to Setting" do Setting.site_contact_email = "admin@example.com" - - expect(instance_presenter.site_contact_email).to eq "admin@example.com" + expect(instance_presenter.contact.email).to eq "admin@example.com" end end - describe "contact_account" do + describe '#account' do around do |example| site_contact_username = Setting.site_contact_username example.run @@ -55,12 +52,11 @@ describe InstancePresenter do it "returns the account for the site contact username" do Setting.site_contact_username = "aaa" account = Fabricate(:account, username: "aaa") - - expect(instance_presenter.contact_account).to eq(account) + expect(instance_presenter.contact.account).to eq(account) end end - describe "user_count" do + describe '#user_count' do it "returns the number of site users" do Rails.cache.write 'user_count', 123 @@ -68,7 +64,7 @@ describe InstancePresenter do end end - describe "status_count" do + describe '#status_count' do it "returns the number of local statuses" do Rails.cache.write 'local_status_count', 234 @@ -76,7 +72,7 @@ describe InstancePresenter do end end - describe "domain_count" do + describe '#domain_count' do it "returns the number of known domains" do Rails.cache.write 'distinct_domain_count', 345 @@ -84,9 +80,9 @@ describe InstancePresenter do end end - describe '#version_number' do - it 'returns Mastodon::Version' do - expect(instance_presenter.version_number).to be(Mastodon::Version) + describe '#version' do + it 'returns string' do + expect(instance_presenter.version).to be_a String end end diff --git a/spec/views/about/show.html.haml_spec.rb b/spec/views/about/show.html.haml_spec.rb index 4eab97da9..bf6e19d2b 100644 --- a/spec/views/about/show.html.haml_spec.rb +++ b/spec/views/about/show.html.haml_spec.rb @@ -12,25 +12,7 @@ describe 'about/show.html.haml', without_verify_partial_doubles: true do end it 'has valid open graph tags' do - instance_presenter = double( - :instance_presenter, - site_title: 'something', - site_short_description: 'something', - site_description: 'something', - version_number: '1.0', - source_url: 'https://github.com/mastodon/mastodon', - open_registrations: false, - thumbnail: nil, - hero: nil, - mascot: nil, - user_count: 420, - status_count: 69, - active_user_count: 420, - contact_account: nil, - sample_accounts: [] - ) - - assign(:instance_presenter, instance_presenter) + assign(:instance_presenter, InstancePresenter.new) render header_tags = view.content_for(:header_tags) -- cgit From 9f65909f42c14d1e56c5f916eb76b156709ac147 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 5 Oct 2022 03:48:06 +0200 Subject: Change public timelines to be filtered by current locale by default (#19291) In the absence of an opt-in to multiple specific languages in the preferences, it makes more sense to filter by the user's presumed language only (interface language or `lang` override) --- app/controllers/api/v1/timelines/public_controller.rb | 1 + app/controllers/api/v1/timelines/tag_controller.rb | 1 + app/models/public_feed.rb | 13 ++++++++++++- app/models/status.rb | 1 - app/models/tag_feed.rb | 2 ++ spec/models/status_spec.rb | 16 ---------------- 6 files changed, 16 insertions(+), 18 deletions(-) (limited to 'app/controllers/api/v1') diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb index d253b744f..15b91d63e 100644 --- a/app/controllers/api/v1/timelines/public_controller.rb +++ b/app/controllers/api/v1/timelines/public_controller.rb @@ -35,6 +35,7 @@ class Api::V1::Timelines::PublicController < Api::BaseController def public_feed PublicFeed.new( current_account, + locale: content_locale, local: truthy_param?(:local), remote: truthy_param?(:remote), only_media: truthy_param?(:only_media) diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb index 64a1db58d..9f3a5b3f1 100644 --- a/app/controllers/api/v1/timelines/tag_controller.rb +++ b/app/controllers/api/v1/timelines/tag_controller.rb @@ -36,6 +36,7 @@ class Api::V1::Timelines::TagController < Api::BaseController TagFeed.new( @tag, current_account, + locale: content_locale, any: params[:any], all: params[:all], none: params[:none], diff --git a/app/models/public_feed.rb b/app/models/public_feed.rb index 5e4c3e1ce..2cf9206d2 100644 --- a/app/models/public_feed.rb +++ b/app/models/public_feed.rb @@ -8,6 +8,7 @@ class PublicFeed # @option [Boolean] :local # @option [Boolean] :remote # @option [Boolean] :only_media + # @option [String] :locale def initialize(account, options = {}) @account = account @options = options @@ -27,6 +28,7 @@ class PublicFeed scope.merge!(remote_only_scope) if remote_only? scope.merge!(account_filters_scope) if account? scope.merge!(media_only_scope) if media_only? + scope.merge!(language_scope) scope.cache_ids.to_a_paginated_by_id(limit, max_id: max_id, since_id: since_id, min_id: min_id) end @@ -83,10 +85,19 @@ class PublicFeed Status.joins(:media_attachments).group(:id) end + def language_scope + if account&.chosen_languages.present? + Status.where(language: account.chosen_languages) + elsif @options[:locale].present? + Status.where(language: @options[:locale]) + else + Status.all + end + end + def account_filters_scope Status.not_excluded_by_account(account).tap do |scope| scope.merge!(Status.not_domain_blocked_by_account(account)) unless local_only? - scope.merge!(Status.in_chosen_languages(account)) if account.chosen_languages.present? end end end diff --git a/app/models/status.rb b/app/models/status.rb index 7eff990aa..de958aaf3 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -95,7 +95,6 @@ class Status < ApplicationRecord scope :without_reblogs, -> { where('statuses.reblog_of_id IS NULL') } scope :with_public_visibility, -> { where(visibility: :public) } scope :tagged_with, ->(tag_ids) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag_ids }) } - scope :in_chosen_languages, ->(account) { where(language: nil).or where(language: account.chosen_languages) } scope :excluding_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced_at: nil }) } scope :including_silenced_accounts, -> { left_outer_joins(:account).where.not(accounts: { silenced_at: nil }) } scope :not_excluded_by_account, ->(account) { where.not(account_id: account.excluded_from_timeline_account_ids) } diff --git a/app/models/tag_feed.rb b/app/models/tag_feed.rb index b8cd63557..58216bddc 100644 --- a/app/models/tag_feed.rb +++ b/app/models/tag_feed.rb @@ -12,6 +12,7 @@ class TagFeed < PublicFeed # @option [Boolean] :local # @option [Boolean] :remote # @option [Boolean] :only_media + # @option [String] :locale def initialize(tag, account, options = {}) @tag = tag super(account, options) @@ -32,6 +33,7 @@ class TagFeed < PublicFeed scope.merge!(remote_only_scope) if remote_only? scope.merge!(account_filters_scope) if account? scope.merge!(media_only_scope) if media_only? + scope.merge!(language_scope) scope.cache_ids.to_a_paginated_by_id(limit, max_id: max_id, since_id: since_id, min_id: min_id) end diff --git a/spec/models/status_spec.rb b/spec/models/status_spec.rb index 130f4d03f..78cc05959 100644 --- a/spec/models/status_spec.rb +++ b/spec/models/status_spec.rb @@ -251,22 +251,6 @@ RSpec.describe Status, type: :model do end end - describe '.in_chosen_languages' do - context 'for accounts with language filters' do - let(:user) { Fabricate(:user, chosen_languages: ['en']) } - - it 'does not include statuses in not in chosen languages' do - status = Fabricate(:status, language: 'de') - expect(Status.in_chosen_languages(user.account)).not_to include status - end - - it 'includes status with unknown language' do - status = Fabricate(:status, language: nil) - expect(Status.in_chosen_languages(user.account)).to include status - end - end - end - describe '.tagged_with' do let(:tag1) { Fabricate(:tag) } let(:tag2) { Fabricate(:tag) } -- cgit From a2ba01132603174c43c5788a95f9ee127b684c0a Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 8 Oct 2022 06:01:11 +0200 Subject: Change privacy policy to be rendered in web UI, add REST API (#19310) Source string no longer localized, Markdown instead of raw HTML --- Gemfile | 1 + Gemfile.lock | 8 +- .../v1/instances/privacy_policies_controller.rb | 18 +++++ app/controllers/privacy_controller.rb | 17 +---- .../mastodon/features/privacy_policy/index.js | 60 +++++++++++++++ .../mastodon/features/ui/components/link_footer.js | 2 +- app/javascript/mastodon/features/ui/index.js | 2 + .../mastodon/features/ui/util/async-components.js | 4 + app/javascript/styles/mastodon/components.scss | 88 +++++++++++++++++++++- app/models/privacy_policy.rb | 77 +++++++++++++++++++ app/serializers/rest/privacy_policy_serializer.rb | 19 +++++ app/views/privacy/show.html.haml | 9 +-- config/i18n-tasks.yml | 1 - config/locales/en.yml | 85 +-------------------- config/routes.rb | 3 +- 15 files changed, 282 insertions(+), 112 deletions(-) create mode 100644 app/controllers/api/v1/instances/privacy_policies_controller.rb create mode 100644 app/javascript/mastodon/features/privacy_policy/index.js create mode 100644 app/models/privacy_policy.rb create mode 100644 app/serializers/rest/privacy_policy_serializer.rb (limited to 'app/controllers/api/v1') diff --git a/Gemfile b/Gemfile index fedd55f2f..34899967b 100644 --- a/Gemfile +++ b/Gemfile @@ -72,6 +72,7 @@ gem 'rack-attack', '~> 6.6' gem 'rack-cors', '~> 1.1', require: 'rack/cors' gem 'rails-i18n', '~> 6.0' gem 'rails-settings-cached', '~> 0.6' +gem 'redcarpet', '~> 3.5' gem 'redis', '~> 4.5', require: ['redis', 'redis/connection/hiredis'] gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock' gem 'rqrcode', '~> 2.1' diff --git a/Gemfile.lock b/Gemfile.lock index 6866ef721..5788c857d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -402,7 +402,6 @@ GEM mime-types-data (~> 3.2015) mime-types-data (3.2022.0105) mini_mime (1.1.2) - mini_portile2 (2.8.0) minitest (5.16.3) msgpack (1.5.4) multi_json (1.15.0) @@ -412,8 +411,7 @@ GEM net-ssh (>= 2.6.5, < 8.0.0) net-ssh (7.0.1) nio4r (2.5.8) - nokogiri (1.13.8) - mini_portile2 (~> 2.8.0) + nokogiri (1.13.8-x86_64-linux) racc (~> 1.4) nsa (0.2.8) activesupport (>= 4.2, < 7) @@ -539,6 +537,7 @@ GEM link_header (~> 0.0, >= 0.0.8) rdf-normalize (0.5.0) rdf (~> 3.2) + redcarpet (3.5.1) redis (4.5.1) redis-namespace (1.9.0) redis (>= 4) @@ -727,7 +726,7 @@ GEM zeitwerk (2.6.0) PLATFORMS - ruby + x86_64-linux DEPENDENCIES active_model_serializers (~> 0.10) @@ -819,6 +818,7 @@ DEPENDENCIES rails-i18n (~> 6.0) rails-settings-cached (~> 0.6) rdf-normalize (~> 0.5) + redcarpet (~> 3.5) redis (~> 4.5) redis-namespace (~> 1.9) rexml (~> 3.2) diff --git a/app/controllers/api/v1/instances/privacy_policies_controller.rb b/app/controllers/api/v1/instances/privacy_policies_controller.rb new file mode 100644 index 000000000..dbd69f54d --- /dev/null +++ b/app/controllers/api/v1/instances/privacy_policies_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Api::V1::Instances::PrivacyPoliciesController < Api::BaseController + skip_before_action :require_authenticated_user!, unless: :whitelist_mode? + + before_action :set_privacy_policy + + def show + expires_in 1.day, public: true + render json: @privacy_policy, serializer: REST::PrivacyPolicySerializer + end + + private + + def set_privacy_policy + @privacy_policy = PrivacyPolicy.current + end +end diff --git a/app/controllers/privacy_controller.rb b/app/controllers/privacy_controller.rb index ced84dbe5..bc98bca51 100644 --- a/app/controllers/privacy_controller.rb +++ b/app/controllers/privacy_controller.rb @@ -1,22 +1,11 @@ # frozen_string_literal: true class PrivacyController < ApplicationController - layout 'public' - - before_action :set_instance_presenter - before_action :set_expires_in + include WebAppControllerConcern skip_before_action :require_functional! - def show; end - - private - - def set_instance_presenter - @instance_presenter = InstancePresenter.new - end - - def set_expires_in - expires_in 0, public: true + def show + expires_in 0, public: true if current_account.nil? end end diff --git a/app/javascript/mastodon/features/privacy_policy/index.js b/app/javascript/mastodon/features/privacy_policy/index.js new file mode 100644 index 000000000..b7ca03d2c --- /dev/null +++ b/app/javascript/mastodon/features/privacy_policy/index.js @@ -0,0 +1,60 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { title } from 'mastodon/initial_state'; +import { Helmet } from 'react-helmet'; +import { FormattedMessage, FormattedDate, injectIntl, defineMessages } from 'react-intl'; +import Column from 'mastodon/components/column'; +import api from 'mastodon/api'; +import Skeleton from 'mastodon/components/skeleton'; + +const messages = defineMessages({ + title: { id: 'privacy_policy.title', defaultMessage: 'Privacy Policy' }, +}); + +export default @injectIntl +class PrivacyPolicy extends React.PureComponent { + + static propTypes = { + intl: PropTypes.object, + }; + + state = { + content: null, + lastUpdated: null, + isLoading: true, + }; + + componentDidMount () { + api().get('/api/v1/instance/privacy_policy').then(({ data }) => { + this.setState({ content: data.content, lastUpdated: data.updated_at, isLoading: false }); + }).catch(() => { + this.setState({ isLoading: false }); + }); + } + + render () { + const { intl } = this.props; + const { isLoading, content, lastUpdated } = this.state; + + return ( + +
+
+

+

: }} />

+
+ +
+
+ + + {intl.formatMessage(messages.title)} - {title} + + + ); + } + +} diff --git a/app/javascript/mastodon/features/ui/components/link_footer.js b/app/javascript/mastodon/features/ui/components/link_footer.js index 2b092a182..c4ce2a985 100644 --- a/app/javascript/mastodon/features/ui/components/link_footer.js +++ b/app/javascript/mastodon/features/ui/components/link_footer.js @@ -54,7 +54,7 @@ class LinkFooter extends React.PureComponent { items.push(); items.push(); items.push(); - items.push(); + items.push(); items.push(); if (profileDirectory) { diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index bc6ff1866..bd1930368 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -52,6 +52,7 @@ import { Explore, FollowRecommendations, About, + PrivacyPolicy, } from './util/async-components'; import { me, title } from '../../initial_state'; import { closeOnboarding, INTRODUCTION_VERSION } from 'mastodon/actions/onboarding'; @@ -173,6 +174,7 @@ class SwitchingColumnsArea extends React.PureComponent { + diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 5907e0772..c79dc014c 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -169,3 +169,7 @@ export function FilterModal () { export function About () { return import(/*webpackChunkName: "features/about" */'../../about'); } + +export function PrivacyPolicy () { + return import(/*webpackChunkName: "features/privacy_policy" */'../../privacy_policy'); +} diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index a3dc4c637..cc8455ce3 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -2283,7 +2283,8 @@ $ui-header-height: 55px; > .scrollable { background: $ui-base-color; - border-radius: 0 0 4px 4px; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; } } @@ -8226,3 +8227,88 @@ noscript { } } } + +.privacy-policy { + background: $ui-base-color; + padding: 20px; + + @media screen and (min-width: $no-gap-breakpoint) { + border-radius: 4px; + } + + &__body { + margin-top: 20px; + color: $secondary-text-color; + font-size: 15px; + line-height: 22px; + + h1, + p, + ul, + ol { + margin-bottom: 20px; + } + + ul { + list-style: disc; + } + + ol { + list-style: decimal; + } + + ul, + ol { + padding-left: 1em; + } + + li { + margin-bottom: 10px; + + &::marker { + color: $darker-text-color; + } + + &:last-child { + margin-bottom: 0; + } + } + + h1 { + color: $primary-text-color; + font-size: 19px; + line-height: 24px; + font-weight: 700; + margin-top: 30px; + + &:first-child { + margin-top: 0; + } + } + + strong { + font-weight: 700; + color: $primary-text-color; + } + + em { + font-style: italic; + } + + a { + color: $highlight-text-color; + text-decoration: underline; + + &:focus, + &:hover, + &:active { + text-decoration: none; + } + } + + hr { + border: 1px solid lighten($ui-base-color, 4%); + margin: 30px 0; + } + } +} diff --git a/app/models/privacy_policy.rb b/app/models/privacy_policy.rb new file mode 100644 index 000000000..b93b6cf35 --- /dev/null +++ b/app/models/privacy_policy.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +class PrivacyPolicy < ActiveModelSerializers::Model + DEFAULT_PRIVACY_POLICY = <<~TXT + This privacy policy describes how %{domain} ("%{domain}", "we", "us") collects, protects and uses the personally identifiable information you may provide through the %{domain} website or its API. The policy also describes the choices available to you regarding our use of your personal information and how you can access and update this information. This policy does not apply to the practices of companies that %{domain} does not own or control, or to individuals that %{domain} does not employ or manage. + + # What information do we collect? + + - **Basic account information**: If you register on this server, you may be asked to enter a username, an e-mail address and a password. You may also enter additional profile information such as a display name and biography, and upload a profile picture and header image. The username, display name, biography, profile picture and header image are always listed publicly. + - **Posts, following and other public information**: The list of people you follow is listed publicly, the same is true for your followers. When you submit a message, the date and time is stored as well as the application you submitted the message from. Messages may contain media attachments, such as pictures and videos. Public and unlisted posts are available publicly. When you feature a post on your profile, that is also publicly available information. Your posts are delivered to your followers, in some cases it means they are delivered to different servers and copies are stored there. When you delete posts, this is likewise delivered to your followers. The action of reblogging or favouriting another post is always public. + - **Direct and followers-only posts**: All posts are stored and processed on the server. Followers-only posts are delivered to your followers and users who are mentioned in them, and direct posts are delivered only to users mentioned in them. In some cases it means they are delivered to different servers and copies are stored there. We make a good faith effort to limit the access to those posts only to authorized persons, but other servers may fail to do so. Therefore it's important to review servers your followers belong to. You may toggle an option to approve and reject new followers manually in the settings. **Please keep in mind that the operators of the server and any receiving server may view such messages**, and that recipients may screenshot, copy or otherwise re-share them. **Do not share any sensitive information over Mastodon.** + - **IPs and other metadata**: When you log in, we record the IP address you log in from, as well as the name of your browser application. All the logged in sessions are available for your review and revocation in the settings. The latest IP address used is stored for up to 12 months. We also may retain server logs which include the IP address of every request to our server. + + # What do we use your information for? + + Any of the information we collect from you may be used in the following ways: + + - To provide the core functionality of Mastodon. You can only interact with other people's content and post your own content when you are logged in. For example, you may follow other people to view their combined posts in your own personalized home timeline. + - To aid moderation of the community, for example comparing your IP address with other known ones to determine ban evasion or other violations. + - The email address you provide may be used to send you information, notifications about other people interacting with your content or sending you messages, and to respond to inquiries, and/or other requests or questions. + + # How do we protect your information? + + We implement a variety of security measures to maintain the safety of your personal information when you enter, submit, or access your personal information. Among other things, your browser session, as well as the traffic between your applications and the API, are secured with SSL, and your password is hashed using a strong one-way algorithm. You may enable two-factor authentication to further secure access to your account. + + # What is our data retention policy? + + We will make a good faith effort to: + + - Retain server logs containing the IP address of all requests to this server, in so far as such logs are kept, no more than 90 days. + - Retain the IP addresses associated with registered users no more than 12 months. + + You can request and download an archive of your content, including your posts, media attachments, profile picture, and header image. + + You may irreversibly delete your account at any time. + + # Do we use cookies? + + Yes. Cookies are small files that a site or its service provider transfers to your computer's hard drive through your Web browser (if you allow). These cookies enable the site to recognize your browser and, if you have a registered account, associate it with your registered account. + + We use cookies to understand and save your preferences for future visits. + + # Do we disclose any information to outside parties? + + We do not sell, trade, or otherwise transfer to outside parties your personally identifiable information. This does not include trusted third parties who assist us in operating our site, conducting our business, or servicing you, so long as those parties agree to keep this information confidential. We may also release your information when we believe release is appropriate to comply with the law, enforce our site policies, or protect ours or others rights, property, or safety. + + Your public content may be downloaded by other servers in the network. Your public and followers-only posts are delivered to the servers where your followers reside, and direct messages are delivered to the servers of the recipients, in so far as those followers or recipients reside on a different server than this. + + When you authorize an application to use your account, depending on the scope of permissions you approve, it may access your public profile information, your following list, your followers, your lists, all your posts, and your favourites. Applications can never access your e-mail address or password. + + # Site usage by children + + If this server is in the EU or the EEA: Our site, products and services are all directed to people who are at least 16 years old. If you are under the age of 16, per the requirements of the GDPR (General Data Protection Regulation) do not use this site. + + If this server is in the USA: Our site, products and services are all directed to people who are at least 13 years old. If you are under the age of 13, per the requirements of COPPA (Children's Online Privacy Protection Act) do not use this site. + + Law requirements can be different if this server is in another jurisdiction. + + ___ + + This document is CC-BY-SA. Originally adapted from the [Discourse privacy policy](https://github.com/discourse/discourse). + TXT + + DEFAULT_UPDATED_AT = DateTime.new(2022, 10, 7).freeze + + attributes :updated_at, :text + + def self.current + custom = Setting.find_by(var: 'site_terms') + + if custom + new(text: custom.value, updated_at: custom.updated_at) + else + new(text: DEFAULT_PRIVACY_POLICY, updated_at: DEFAULT_UPDATED_AT) + end + end +end diff --git a/app/serializers/rest/privacy_policy_serializer.rb b/app/serializers/rest/privacy_policy_serializer.rb new file mode 100644 index 000000000..f0572e714 --- /dev/null +++ b/app/serializers/rest/privacy_policy_serializer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class REST::PrivacyPolicySerializer < ActiveModel::Serializer + attributes :updated_at, :content + + def updated_at + object.updated_at.iso8601 + end + + def content + markdown.render(object.text % { domain: Rails.configuration.x.local_domain }) + end + + private + + def markdown + @markdown ||= Redcarpet::Markdown.new(Redcarpet::Render::HTML, escape_html: true, no_images: true) + end +end diff --git a/app/views/privacy/show.html.haml b/app/views/privacy/show.html.haml index cdd38a595..cfc285925 100644 --- a/app/views/privacy/show.html.haml +++ b/app/views/privacy/show.html.haml @@ -1,9 +1,4 @@ - content_for :page_title do - = t('terms.title', instance: site_hostname) + = t('privacy_policy.title') -.grid - .column-0 - .box-widget - .rich-formatting= @instance_presenter.privacy_policy.html_safe.presence || t('terms.body_html') - .column-1 - = render 'application/sidebar' += render 'shared/web_app' diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index 1bebae5e9..c1da42bd8 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -40,7 +40,6 @@ ignore_missing: - 'errors.messages.*' - 'activerecord.errors.models.doorkeeper/*' - 'sessions.{browsers,platforms}.*' - - 'terms.body_html' - 'application_mailer.salutation' - 'errors.500' - 'auth.providers.*' diff --git a/config/locales/en.yml b/config/locales/en.yml index 505a2f9fc..cdac4fb54 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1405,6 +1405,8 @@ en: other: Other posting_defaults: Posting defaults public_timelines: Public timelines + privacy_policy: + title: Privacy Policy reactions: errors: limit_reached: Limit of different reactions reached @@ -1614,89 +1616,6 @@ en: too_late: It is too late to appeal this strike tags: does_not_match_previous_name: does not match the previous name - terms: - body_html: | -

Privacy Policy

-

What information do we collect?

- -
    -
  • Basic account information: If you register on this server, you may be asked to enter a username, an e-mail address and a password. You may also enter additional profile information such as a display name and biography, and upload a profile picture and header image. The username, display name, biography, profile picture and header image are always listed publicly.
  • -
  • Posts, following and other public information: The list of people you follow is listed publicly, the same is true for your followers. When you submit a message, the date and time is stored as well as the application you submitted the message from. Messages may contain media attachments, such as pictures and videos. Public and unlisted posts are available publicly. When you feature a post on your profile, that is also publicly available information. Your posts are delivered to your followers, in some cases it means they are delivered to different servers and copies are stored there. When you delete posts, this is likewise delivered to your followers. The action of reblogging or favouriting another post is always public.
  • -
  • Direct and followers-only posts: All posts are stored and processed on the server. Followers-only posts are delivered to your followers and users who are mentioned in them, and direct posts are delivered only to users mentioned in them. In some cases it means they are delivered to different servers and copies are stored there. We make a good faith effort to limit the access to those posts only to authorized persons, but other servers may fail to do so. Therefore it's important to review servers your followers belong to. You may toggle an option to approve and reject new followers manually in the settings. Please keep in mind that the operators of the server and any receiving server may view such messages, and that recipients may screenshot, copy or otherwise re-share them. Do not share any sensitive information over Mastodon.
  • -
  • IPs and other metadata: When you log in, we record the IP address you log in from, as well as the name of your browser application. All the logged in sessions are available for your review and revocation in the settings. The latest IP address used is stored for up to 12 months. We also may retain server logs which include the IP address of every request to our server.
  • -
- -
- -

What do we use your information for?

- -

Any of the information we collect from you may be used in the following ways:

- -
    -
  • To provide the core functionality of Mastodon. You can only interact with other people's content and post your own content when you are logged in. For example, you may follow other people to view their combined posts in your own personalized home timeline.
  • -
  • To aid moderation of the community, for example comparing your IP address with other known ones to determine ban evasion or other violations.
  • -
  • The email address you provide may be used to send you information, notifications about other people interacting with your content or sending you messages, and to respond to inquiries, and/or other requests or questions.
  • -
- -
- -

How do we protect your information?

- -

We implement a variety of security measures to maintain the safety of your personal information when you enter, submit, or access your personal information. Among other things, your browser session, as well as the traffic between your applications and the API, are secured with SSL, and your password is hashed using a strong one-way algorithm. You may enable two-factor authentication to further secure access to your account.

- -
- -

What is our data retention policy?

- -

We will make a good faith effort to:

- -
    -
  • Retain server logs containing the IP address of all requests to this server, in so far as such logs are kept, no more than 90 days.
  • -
  • Retain the IP addresses associated with registered users no more than 12 months.
  • -
- -

You can request and download an archive of your content, including your posts, media attachments, profile picture, and header image.

- -

You may irreversibly delete your account at any time.

- -
- -

Do we use cookies?

- -

Yes. Cookies are small files that a site or its service provider transfers to your computer's hard drive through your Web browser (if you allow). These cookies enable the site to recognize your browser and, if you have a registered account, associate it with your registered account.

- -

We use cookies to understand and save your preferences for future visits.

- -
- -

Do we disclose any information to outside parties?

- -

We do not sell, trade, or otherwise transfer to outside parties your personally identifiable information. This does not include trusted third parties who assist us in operating our site, conducting our business, or servicing you, so long as those parties agree to keep this information confidential. We may also release your information when we believe release is appropriate to comply with the law, enforce our site policies, or protect ours or others rights, property, or safety.

- -

Your public content may be downloaded by other servers in the network. Your public and followers-only posts are delivered to the servers where your followers reside, and direct messages are delivered to the servers of the recipients, in so far as those followers or recipients reside on a different server than this.

- -

When you authorize an application to use your account, depending on the scope of permissions you approve, it may access your public profile information, your following list, your followers, your lists, all your posts, and your favourites. Applications can never access your e-mail address or password.

- -
- -

Site usage by children

- -

If this server is in the EU or the EEA: Our site, products and services are all directed to people who are at least 16 years old. If you are under the age of 16, per the requirements of the GDPR (General Data Protection Regulation) do not use this site.

- -

If this server is in the USA: Our site, products and services are all directed to people who are at least 13 years old. If you are under the age of 13, per the requirements of COPPA (Children's Online Privacy Protection Act) do not use this site.

- -

Law requirements can be different if this server is in another jurisdiction.

- -
- -

Changes to our Privacy Policy

- -

If we decide to change our privacy policy, we will post those changes on this page.

- -

This document is CC-BY-SA. It was last updated May 26, 2022.

- -

Originally adapted from the Discourse privacy policy.

- title: "%{instance} Privacy Policy" themes: contrast: Mastodon (High contrast) default: Mastodon (Dark) diff --git a/config/routes.rb b/config/routes.rb index 472e6aa6b..e6098cd17 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -486,8 +486,9 @@ Rails.application.routes.draw do resource :instance, only: [:show] do resources :peers, only: [:index], controller: 'instances/peers' - resource :activity, only: [:show], controller: 'instances/activity' resources :rules, only: [:index], controller: 'instances/rules' + resource :privacy_policy, only: [:show], controller: 'instances/privacy_policies' + resource :activity, only: [:show], controller: 'instances/activity' end resource :domain_blocks, only: [:show, :create, :destroy] -- cgit From 45ebdb72ca1678eb30e6087f61bd019c7ea903f4 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 8 Oct 2022 16:45:40 +0200 Subject: Add support for language preferences for trending statuses and links (#18288) --- app/controllers/admin/trends/links_controller.rb | 1 + .../admin/trends/statuses_controller.rb | 1 + app/controllers/api/v1/trends/links_controller.rb | 4 +- app/mailers/admin_mailer.rb | 4 +- app/models/preview_card.rb | 1 + app/models/preview_card_trend.rb | 17 ++++ app/models/status.rb | 1 + app/models/status_trend.rb | 21 +++++ app/models/trends.rb | 6 +- app/models/trends/base.rb | 4 + app/models/trends/links.rb | 98 +++++++++++++++------- app/models/trends/preview_card_filter.rb | 25 ++++-- app/models/trends/status_filter.rb | 25 ++++-- app/models/trends/statuses.rb | 82 ++++++++++-------- .../admin/trends/links/_preview_card.html.haml | 4 +- app/views/admin/trends/links/index.html.haml | 2 +- app/views/admin/trends/statuses/_status.html.haml | 4 +- app/views/admin/trends/statuses/index.html.haml | 2 +- .../admin_mailer/_new_trending_links.text.erb | 8 +- .../admin_mailer/_new_trending_statuses.text.erb | 8 +- config/locales/en.yml | 4 - db/migrate/20220824233535_create_status_trends.rb | 12 +++ .../20221006061337_create_preview_card_trends.rb | 11 +++ db/schema.rb | 25 +++++- spec/fabricators/account_fabricator.rb | 1 + spec/mailers/previews/admin_mailer_preview.rb | 2 +- spec/models/preview_card_trend_spec.rb | 4 + spec/models/status_trend_spec.rb | 4 + spec/models/trends/statuses_spec.rb | 14 ++-- 29 files changed, 274 insertions(+), 121 deletions(-) create mode 100644 app/models/preview_card_trend.rb create mode 100644 app/models/status_trend.rb create mode 100644 db/migrate/20220824233535_create_status_trends.rb create mode 100644 db/migrate/20221006061337_create_preview_card_trends.rb create mode 100644 spec/models/preview_card_trend_spec.rb create mode 100644 spec/models/status_trend_spec.rb (limited to 'app/controllers/api/v1') diff --git a/app/controllers/admin/trends/links_controller.rb b/app/controllers/admin/trends/links_controller.rb index a497eae41..a3454f2da 100644 --- a/app/controllers/admin/trends/links_controller.rb +++ b/app/controllers/admin/trends/links_controller.rb @@ -4,6 +4,7 @@ class Admin::Trends::LinksController < Admin::BaseController def index authorize :preview_card, :review? + @locales = PreviewCardTrend.pluck('distinct language') @preview_cards = filtered_preview_cards.page(params[:page]) @form = Trends::PreviewCardBatch.new end diff --git a/app/controllers/admin/trends/statuses_controller.rb b/app/controllers/admin/trends/statuses_controller.rb index c538962f9..2b8226138 100644 --- a/app/controllers/admin/trends/statuses_controller.rb +++ b/app/controllers/admin/trends/statuses_controller.rb @@ -4,6 +4,7 @@ class Admin::Trends::StatusesController < Admin::BaseController def index authorize :status, :review? + @locales = StatusTrend.pluck('distinct language') @statuses = filtered_statuses.page(params[:page]) @form = Trends::StatusBatch.new end diff --git a/app/controllers/api/v1/trends/links_controller.rb b/app/controllers/api/v1/trends/links_controller.rb index 1a9f918f2..8ff3b364e 100644 --- a/app/controllers/api/v1/trends/links_controller.rb +++ b/app/controllers/api/v1/trends/links_controller.rb @@ -28,7 +28,9 @@ class Api::V1::Trends::LinksController < Api::BaseController end def links_from_trends - Trends.links.query.allowed.in_locale(content_locale) + scope = Trends.links.query.allowed.in_locale(content_locale) + scope = scope.filtered_for(current_account) if user_signed_in? + scope end def insert_pagination_headers diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb index f416977d8..bc6d87ae6 100644 --- a/app/mailers/admin_mailer.rb +++ b/app/mailers/admin_mailer.rb @@ -4,6 +4,7 @@ class AdminMailer < ApplicationMailer layout 'plain_mailer' helper :accounts + helper :languages def new_report(recipient, report) @report = report @@ -37,11 +38,8 @@ class AdminMailer < ApplicationMailer def new_trends(recipient, links, tags, statuses) @links = links - @lowest_trending_link = Trends.links.query.allowed.limit(Trends.links.options[:review_threshold]).last @tags = tags - @lowest_trending_tag = Trends.tags.query.allowed.limit(Trends.tags.options[:review_threshold]).last @statuses = statuses - @lowest_trending_status = Trends.statuses.query.allowed.limit(Trends.statuses.options[:review_threshold]).last @me = recipient @instance = Rails.configuration.x.local_domain diff --git a/app/models/preview_card.rb b/app/models/preview_card.rb index c49c51a1b..b5d3f9c8f 100644 --- a/app/models/preview_card.rb +++ b/app/models/preview_card.rb @@ -48,6 +48,7 @@ class PreviewCard < ApplicationRecord enum link_type: [:unknown, :article] has_and_belongs_to_many :statuses + has_one :trend, class_name: 'PreviewCardTrend', inverse_of: :preview_card, dependent: :destroy has_attached_file :image, processors: [:thumbnail, :blurhash_transcoder], styles: ->(f) { image_styles(f) }, convert_options: { all: '-quality 80 -strip' }, validate_media_type: false diff --git a/app/models/preview_card_trend.rb b/app/models/preview_card_trend.rb new file mode 100644 index 000000000..018400dfa --- /dev/null +++ b/app/models/preview_card_trend.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: preview_card_trends +# +# id :bigint(8) not null, primary key +# preview_card_id :bigint(8) not null +# score :float default(0.0), not null +# rank :integer default(0), not null +# allowed :boolean default(FALSE), not null +# language :string +# +class PreviewCardTrend < ApplicationRecord + belongs_to :preview_card + scope :allowed, -> { where(allowed: true) } +end diff --git a/app/models/status.rb b/app/models/status.rb index de958aaf3..4805abfea 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -75,6 +75,7 @@ class Status < ApplicationRecord has_one :notification, as: :activity, dependent: :destroy has_one :status_stat, inverse_of: :status has_one :poll, inverse_of: :status, dependent: :destroy + has_one :trend, class_name: 'StatusTrend', inverse_of: :status validates :uri, uniqueness: true, presence: true, unless: :local? validates :text, presence: true, unless: -> { with_media? || reblog? } diff --git a/app/models/status_trend.rb b/app/models/status_trend.rb new file mode 100644 index 000000000..33fd6b004 --- /dev/null +++ b/app/models/status_trend.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: status_trends +# +# id :bigint(8) not null, primary key +# status_id :bigint(8) not null +# account_id :bigint(8) not null +# score :float default(0.0), not null +# rank :integer default(0), not null +# allowed :boolean default(FALSE), not null +# language :string +# + +class StatusTrend < ApplicationRecord + belongs_to :status + belongs_to :account + + scope :allowed, -> { where(allowed: true) } +end diff --git a/app/models/trends.rb b/app/models/trends.rb index d886be89a..151d734f9 100644 --- a/app/models/trends.rb +++ b/app/models/trends.rb @@ -26,7 +26,7 @@ module Trends end def self.request_review! - return unless enabled? + return if skip_review? || !enabled? links_requiring_review = links.request_review tags_requiring_review = tags.request_review @@ -43,6 +43,10 @@ module Trends Setting.trends end + def skip_review? + Setting.trendable_by_default + end + def self.available_locales @available_locales ||= I18n.available_locales.map { |locale| locale.to_s.split(/[_-]/).first }.uniq end diff --git a/app/models/trends/base.rb b/app/models/trends/base.rb index 047111248..a189f11f2 100644 --- a/app/models/trends/base.rb +++ b/app/models/trends/base.rb @@ -98,4 +98,8 @@ class Trends::Base pipeline.rename(from_key, to_key) end end + + def skip_review? + Setting.trendable_by_default + end end diff --git a/app/models/trends/links.rb b/app/models/trends/links.rb index 604894cd6..8808b3ab6 100644 --- a/app/models/trends/links.rb +++ b/app/models/trends/links.rb @@ -11,6 +11,40 @@ class Trends::Links < Trends::Base decay_threshold: 1, } + class Query < Trends::Query + def filtered_for!(account) + @account = account + self + end + + def filtered_for(account) + clone.filtered_for!(account) + end + + def to_arel + scope = PreviewCard.joins(:trend).reorder(score: :desc) + scope = scope.reorder(language_order_clause.desc, score: :desc) if preferred_languages.present? + scope = scope.merge(PreviewCardTrend.allowed) if @allowed + scope = scope.offset(@offset) if @offset.present? + scope = scope.limit(@limit) if @limit.present? + scope + end + + private + + def language_order_clause + Arel::Nodes::Case.new.when(PreviewCardTrend.arel_table[:language].in(preferred_languages)).then(1).else(0) + end + + def preferred_languages + if @account&.chosen_languages.present? + @account.chosen_languages + else + @locale + end + end + end + def register(status, at_time = Time.now.utc) original_status = status.proper @@ -28,24 +62,33 @@ class Trends::Links < Trends::Base record_used_id(preview_card.id, at_time) end + def query + Query.new(key_prefix, klass) + end + def refresh(at_time = Time.now.utc) - preview_cards = PreviewCard.where(id: (recently_used_ids(at_time) + currently_trending_ids(false, -1)).uniq) + preview_cards = PreviewCard.where(id: (recently_used_ids(at_time) + PreviewCardTrend.pluck(:preview_card_id)).uniq) calculate_scores(preview_cards, at_time) end def request_review - preview_cards = PreviewCard.where(id: currently_trending_ids(false, -1)) + PreviewCardTrend.pluck('distinct language').flat_map do |language| + score_at_threshold = PreviewCardTrend.where(language: language, allowed: true).order(rank: :desc).where('rank <= ?', options[:review_threshold]).first&.score || 0 + preview_card_trends = PreviewCardTrend.where(language: language, allowed: false).joins(:preview_card) - preview_cards.filter_map do |preview_card| - next unless would_be_trending?(preview_card.id) && !preview_card.trendable? && preview_card.requires_review_notification? + preview_card_trends.filter_map do |trend| + preview_card = trend.preview_card - if preview_card.provider.nil? - preview_card.provider = PreviewCardProvider.create(domain: preview_card.domain, requested_review_at: Time.now.utc) - else - preview_card.provider.touch(:requested_review_at) - end + next unless trend.score > score_at_threshold && !preview_card.trendable? && preview_card.requires_review_notification? + + if preview_card.provider.nil? + preview_card.provider = PreviewCardProvider.create(domain: preview_card.domain, requested_review_at: Time.now.utc) + else + preview_card.provider.touch(:requested_review_at) + end - preview_card + preview_card + end end end @@ -62,10 +105,7 @@ class Trends::Links < Trends::Base private def calculate_scores(preview_cards, at_time) - global_items = [] - locale_items = Hash.new { |h, key| h[key] = [] } - - preview_cards.each do |preview_card| + items = preview_cards.map do |preview_card| expected = preview_card.history.get(at_time - 1.day).accounts.to_f expected = 1.0 if expected.zero? observed = preview_card.history.get(at_time).accounts.to_f @@ -89,26 +129,24 @@ class Trends::Links < Trends::Base preview_card.update_columns(max_score: max_score, max_score_at: max_time) end - decaying_score = max_score * (0.5**((at_time.to_f - max_time.to_f) / options[:max_score_halflife].to_f)) - - next unless decaying_score >= options[:decay_threshold] + decaying_score = begin + if max_score.zero? || !valid_locale?(preview_card.language) + 0 + else + max_score * (0.5**((at_time.to_f - max_time.to_f) / options[:max_score_halflife].to_f)) + end + end - global_items << { score: decaying_score, item: preview_card } - locale_items[preview_card.language] << { score: decaying_score, item: preview_card } if valid_locale?(preview_card.language) + [decaying_score, preview_card] end - replace_items('', global_items) + to_insert = items.filter { |(score, _)| score >= options[:decay_threshold] } + to_delete = items.filter { |(score, _)| score < options[:decay_threshold] } - Trends.available_locales.each do |locale| - replace_items(":#{locale}", locale_items[locale]) + PreviewCardTrend.transaction do + PreviewCardTrend.upsert_all(to_insert.map { |(score, preview_card)| { preview_card_id: preview_card.id, score: score, language: preview_card.language, allowed: preview_card.trendable? || false } }, unique_by: :preview_card_id) if to_insert.any? + PreviewCardTrend.where(preview_card_id: to_delete.map { |(_, preview_card)| preview_card.id }).delete_all if to_delete.any? + PreviewCardTrend.connection.exec_update('UPDATE preview_card_trends SET rank = t0.calculated_rank FROM (SELECT id, row_number() OVER w AS calculated_rank FROM preview_card_trends WINDOW w AS (PARTITION BY language ORDER BY score DESC)) t0 WHERE preview_card_trends.id = t0.id') end end - - def filter_for_allowed_items(items) - items.select { |item| item[:item].trendable? } - end - - def would_be_trending?(id) - score(id) > score_at_rank(options[:review_threshold] - 1) - end end diff --git a/app/models/trends/preview_card_filter.rb b/app/models/trends/preview_card_filter.rb index 25add58c8..0a81146d4 100644 --- a/app/models/trends/preview_card_filter.rb +++ b/app/models/trends/preview_card_filter.rb @@ -13,10 +13,10 @@ class Trends::PreviewCardFilter end def results - scope = PreviewCard.unscoped + scope = initial_scope params.each do |key, value| - next if %w(page locale).include?(key.to_s) + next if %w(page).include?(key.to_s) scope.merge!(scope_for(key, value.to_s.strip)) if value.present? end @@ -26,21 +26,30 @@ class Trends::PreviewCardFilter private + def initial_scope + PreviewCard.select(PreviewCard.arel_table[Arel.star]) + .joins(:trend) + .eager_load(:trend) + .reorder(score: :desc) + end + def scope_for(key, value) case key.to_s when 'trending' trending_scope(value) + when 'locale' + PreviewCardTrend.where(language: value) else raise "Unknown filter: #{key}" end end def trending_scope(value) - scope = Trends.links.query - - scope = scope.in_locale(@params[:locale].to_s) if @params[:locale].present? - scope = scope.allowed if value == 'allowed' - - scope.to_arel + case value + when 'allowed' + PreviewCardTrend.allowed + else + PreviewCardTrend.all + end end end diff --git a/app/models/trends/status_filter.rb b/app/models/trends/status_filter.rb index 7c453e339..cb0f75d67 100644 --- a/app/models/trends/status_filter.rb +++ b/app/models/trends/status_filter.rb @@ -13,10 +13,10 @@ class Trends::StatusFilter end def results - scope = Status.unscoped.kept + scope = initial_scope params.each do |key, value| - next if %w(page locale).include?(key.to_s) + next if %w(page).include?(key.to_s) scope.merge!(scope_for(key, value.to_s.strip)) if value.present? end @@ -26,21 +26,30 @@ class Trends::StatusFilter private + def initial_scope + Status.select(Status.arel_table[Arel.star]) + .joins(:trend) + .eager_load(:trend) + .reorder(score: :desc) + end + def scope_for(key, value) case key.to_s when 'trending' trending_scope(value) + when 'locale' + StatusTrend.where(language: value) else raise "Unknown filter: #{key}" end end def trending_scope(value) - scope = Trends.statuses.query - - scope = scope.in_locale(@params[:locale].to_s) if @params[:locale].present? - scope = scope.allowed if value == 'allowed' - - scope.to_arel + case value + when 'allowed' + StatusTrend.allowed + else + StatusTrend.all + end end end diff --git a/app/models/trends/statuses.rb b/app/models/trends/statuses.rb index 777065d3e..c9ef7c8f2 100644 --- a/app/models/trends/statuses.rb +++ b/app/models/trends/statuses.rb @@ -20,13 +20,27 @@ class Trends::Statuses < Trends::Base clone.filtered_for!(account) end + def to_arel + scope = Status.joins(:trend).reorder(score: :desc) + scope = scope.reorder(language_order_clause.desc, score: :desc) if preferred_languages.present? + scope = scope.merge(StatusTrend.allowed) if @allowed + scope = scope.not_excluded_by_account(@account).not_domain_blocked_by_account(@account) if @account.present? + scope = scope.offset(@offset) if @offset.present? + scope = scope.limit(@limit) if @limit.present? + scope + end + private - def apply_scopes(scope) - if @account.nil? - scope + def language_order_clause + Arel::Nodes::Case.new.when(StatusTrend.arel_table[:language].in(preferred_languages)).then(1).else(0) + end + + def preferred_languages + if @account&.chosen_languages.present? + @account.chosen_languages else - scope.not_excluded_by_account(@account).not_domain_blocked_by_account(@account) + @locale end end end @@ -36,9 +50,6 @@ class Trends::Statuses < Trends::Base end def add(status, _account_id, at_time = Time.now.utc) - # We rely on the total reblogs and favourites count, so we - # don't record which account did the what and when here - record_used_id(status.id, at_time) end @@ -47,18 +58,23 @@ class Trends::Statuses < Trends::Base end def refresh(at_time = Time.now.utc) - statuses = Status.where(id: (recently_used_ids(at_time) + currently_trending_ids(false, -1)).uniq).includes(:account, :media_attachments) + statuses = Status.where(id: (recently_used_ids(at_time) + StatusTrend.pluck(:status_id)).uniq).includes(:status_stat, :account) calculate_scores(statuses, at_time) end def request_review - statuses = Status.where(id: currently_trending_ids(false, -1)).includes(:account) + StatusTrend.pluck('distinct language').flat_map do |language| + score_at_threshold = StatusTrend.where(language: language, allowed: true).order(rank: :desc).where('rank <= ?', options[:review_threshold]).first&.score || 0 + status_trends = StatusTrend.where(language: language, allowed: false).joins(:status).includes(status: :account) - statuses.filter_map do |status| - next unless would_be_trending?(status.id) && !status.trendable? && status.requires_review_notification? + status_trends.filter_map do |trend| + status = trend.status - status.account.touch(:requested_review_at) - status + if trend.score > score_at_threshold && !status.trendable? && status.requires_review_notification? + status.account.touch(:requested_review_at) + status + end + end end end @@ -75,14 +91,11 @@ class Trends::Statuses < Trends::Base private def eligible?(status) - status.public_visibility? && status.account.discoverable? && !status.account.silenced? && status.spoiler_text.blank? && !status.sensitive? && !status.reply? + status.public_visibility? && status.account.discoverable? && !status.account.silenced? && status.spoiler_text.blank? && !status.sensitive? && !status.reply? && valid_locale?(status.language) end def calculate_scores(statuses, at_time) - global_items = [] - locale_items = Hash.new { |h, key| h[key] = [] } - - statuses.each do |status| + items = statuses.map do |status| expected = 1.0 observed = (status.reblogs_count + status.favourites_count).to_f @@ -94,29 +107,24 @@ class Trends::Statuses < Trends::Base end end - decaying_score = score * (0.5**((at_time.to_f - status.created_at.to_f) / options[:score_halflife].to_f)) - - next unless decaying_score >= options[:decay_threshold] + decaying_score = begin + if score.zero? || !eligible?(status) + 0 + else + score * (0.5**((at_time.to_f - status.created_at.to_f) / options[:score_halflife].to_f)) + end + end - global_items << { score: decaying_score, item: status } - locale_items[status.language] << { account_id: status.account_id, score: decaying_score, item: status } if valid_locale?(status.language) + [decaying_score, status] end - replace_items('', global_items) + to_insert = items.filter { |(score, _)| score >= options[:decay_threshold] } + to_delete = items.filter { |(score, _)| score < options[:decay_threshold] } - Trends.available_locales.each do |locale| - replace_items(":#{locale}", locale_items[locale]) + StatusTrend.transaction do + StatusTrend.upsert_all(to_insert.map { |(score, status)| { status_id: status.id, account_id: status.account_id, score: score, language: status.language, allowed: status.trendable? || false } }, unique_by: :status_id) if to_insert.any? + StatusTrend.where(status_id: to_delete.map { |(_, status)| status.id }).delete_all if to_delete.any? + StatusTrend.connection.exec_update('UPDATE status_trends SET rank = t0.calculated_rank FROM (SELECT id, row_number() OVER w AS calculated_rank FROM status_trends WINDOW w AS (PARTITION BY language ORDER BY score DESC)) t0 WHERE status_trends.id = t0.id') end end - - def filter_for_allowed_items(items) - # Show only one status per account, pick the one with the highest score - # that's also eligible to trend - - items.group_by { |item| item[:account_id] }.values.filter_map { |account_items| account_items.select { |item| item[:item].trendable? && item[:item].account.discoverable? }.max_by { |item| item[:score] } } - end - - def would_be_trending?(id) - score(id) > score_at_rank(options[:review_threshold] - 1) - end end diff --git a/app/views/admin/trends/links/_preview_card.html.haml b/app/views/admin/trends/links/_preview_card.html.haml index 7d4897c7e..8812feb31 100644 --- a/app/views/admin/trends/links/_preview_card.html.haml +++ b/app/views/admin/trends/links/_preview_card.html.haml @@ -18,9 +18,9 @@ = t('admin.trends.links.shared_by_over_week', count: preview_card.history.reduce(0) { |sum, day| sum + day.accounts }) - - if preview_card.trendable? && (rank = Trends.links.rank(preview_card.id, locale: params[:locale].presence)) + - if preview_card.trend.allowed? • - %abbr{ title: t('admin.trends.tags.current_score', score: Trends.links.score(preview_card.id, locale: params[:locale].presence)) }= t('admin.trends.tags.trending_rank', rank: rank + 1) + %abbr{ title: t('admin.trends.tags.current_score', score: preview_card.trend.score) }= t('admin.trends.tags.trending_rank', rank: preview_card.trend.rank) - if preview_card.decaying? • diff --git a/app/views/admin/trends/links/index.html.haml b/app/views/admin/trends/links/index.html.haml index 49a53d979..7b5122cf1 100644 --- a/app/views/admin/trends/links/index.html.haml +++ b/app/views/admin/trends/links/index.html.haml @@ -16,7 +16,7 @@ .filter-subset.filter-subset--with-select %strong= t('admin.follow_recommendations.language') .input.select.optional - = select_tag :locale, options_for_select(Trends.available_locales.map { |key| [standard_locale_name(key), key] }, params[:locale]), include_blank: true + = select_tag :locale, options_for_select(@locales.map { |key| [standard_locale_name(key), key] }, params[:locale]), include_blank: true .filter-subset %strong= t('admin.trends.trending') %ul diff --git a/app/views/admin/trends/statuses/_status.html.haml b/app/views/admin/trends/statuses/_status.html.haml index e4d75bbb9..f35e13d12 100644 --- a/app/views/admin/trends/statuses/_status.html.haml +++ b/app/views/admin/trends/statuses/_status.html.haml @@ -25,9 +25,9 @@ - if status.trendable? && !status.account.discoverable? • = t('admin.trends.statuses.not_discoverable') - - if status.trendable? && (rank = Trends.statuses.rank(status.id, locale: params[:locale].presence)) + - if status.trend.allowed? • - %abbr{ title: t('admin.trends.tags.current_score', score: Trends.statuses.score(status.id, locale: params[:locale].presence)) }= t('admin.trends.tags.trending_rank', rank: rank + 1) + %abbr{ title: t('admin.trends.tags.current_score', score: status.trend.score) }= t('admin.trends.tags.trending_rank', rank: status.trend.rank) - elsif status.requires_review? • = t('admin.trends.pending_review') diff --git a/app/views/admin/trends/statuses/index.html.haml b/app/views/admin/trends/statuses/index.html.haml index b0059b20d..a42d60b00 100644 --- a/app/views/admin/trends/statuses/index.html.haml +++ b/app/views/admin/trends/statuses/index.html.haml @@ -16,7 +16,7 @@ .filter-subset.filter-subset--with-select %strong= t('admin.follow_recommendations.language') .input.select.optional - = select_tag :locale, options_for_select(Trends.available_locales.map { |key| [standard_locale_name(key), key]}, params[:locale]), include_blank: true + = select_tag :locale, options_for_select(@locales.map { |key| [standard_locale_name(key), key] }, params[:locale]), include_blank: true .filter-subset %strong= t('admin.trends.trending') %ul diff --git a/app/views/admin_mailer/_new_trending_links.text.erb b/app/views/admin_mailer/_new_trending_links.text.erb index 405926fdd..602e12793 100644 --- a/app/views/admin_mailer/_new_trending_links.text.erb +++ b/app/views/admin_mailer/_new_trending_links.text.erb @@ -2,13 +2,7 @@ <% @links.each do |link| %> - <%= link.title %> • <%= link.url %> - <%= raw t('admin.trends.links.usage_comparison', today: link.history.get(Time.now.utc).accounts, yesterday: link.history.get(Time.now.utc - 1.day).accounts) %> • <%= t('admin.trends.tags.current_score', score: Trends.links.score(link.id).round(2)) %> -<% end %> - -<% if @lowest_trending_link %> -<%= raw t('admin_mailer.new_trends.new_trending_links.requirements', lowest_link_title: @lowest_trending_link.title, lowest_link_score: Trends.links.score(@lowest_trending_link.id).round(2), rank: Trends.links.options[:review_threshold]) %> -<% else %> -<%= raw t('admin_mailer.new_trends.new_trending_links.no_approved_links') %> + <%= standard_locale_name(link.language) %> • <%= raw t('admin.trends.links.usage_comparison', today: link.history.get(Time.now.utc).accounts, yesterday: link.history.get(Time.now.utc - 1.day).accounts) %> • <%= t('admin.trends.tags.current_score', score: link.trend.score.round(2)) %> <% end %> <%= raw t('application_mailer.view')%> <%= admin_trends_links_url %> diff --git a/app/views/admin_mailer/_new_trending_statuses.text.erb b/app/views/admin_mailer/_new_trending_statuses.text.erb index 8d11a80c2..1ed3ae857 100644 --- a/app/views/admin_mailer/_new_trending_statuses.text.erb +++ b/app/views/admin_mailer/_new_trending_statuses.text.erb @@ -2,13 +2,7 @@ <% @statuses.each do |status| %> - <%= ActivityPub::TagManager.instance.url_for(status) %> - <%= raw t('admin.trends.tags.current_score', score: Trends.statuses.score(status.id).round(2)) %> -<% end %> - -<% if @lowest_trending_status %> -<%= raw t('admin_mailer.new_trends.new_trending_statuses.requirements', lowest_status_url: ActivityPub::TagManager.instance.url_for(@lowest_trending_status), lowest_status_score: Trends.statuses.score(@lowest_trending_status.id).round(2), rank: Trends.statuses.options[:review_threshold]) %> -<% else %> -<%= raw t('admin_mailer.new_trends.new_trending_statuses.no_approved_statuses') %> + <%= standard_locale_name(status.language) %> • <%= raw t('admin.trends.tags.current_score', score: status.trend.score.round(2)) %> <% end %> <%= raw t('application_mailer.view')%> <%= admin_trends_statuses_url %> diff --git a/config/locales/en.yml b/config/locales/en.yml index cdac4fb54..bd913a5ca 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -939,12 +939,8 @@ en: new_trends: body: 'The following items need a review before they can be displayed publicly:' new_trending_links: - no_approved_links: There are currently no approved trending links. - requirements: 'Any of these candidates could surpass the #%{rank} approved trending link, which is currently "%{lowest_link_title}" with a score of %{lowest_link_score}.' title: Trending links new_trending_statuses: - no_approved_statuses: There are currently no approved trending posts. - requirements: 'Any of these candidates could surpass the #%{rank} approved trending post, which is currently %{lowest_status_url} with a score of %{lowest_status_score}.' title: Trending posts new_trending_tags: no_approved_tags: There are currently no approved trending hashtags. diff --git a/db/migrate/20220824233535_create_status_trends.rb b/db/migrate/20220824233535_create_status_trends.rb new file mode 100644 index 000000000..cea0abf35 --- /dev/null +++ b/db/migrate/20220824233535_create_status_trends.rb @@ -0,0 +1,12 @@ +class CreateStatusTrends < ActiveRecord::Migration[6.1] + def change + create_table :status_trends do |t| + t.references :status, null: false, foreign_key: { on_delete: :cascade }, index: { unique: true } + t.references :account, null: false, foreign_key: { on_delete: :cascade } + t.float :score, null: false, default: 0 + t.integer :rank, null: false, default: 0 + t.boolean :allowed, null: false, default: false + t.string :language + end + end +end diff --git a/db/migrate/20221006061337_create_preview_card_trends.rb b/db/migrate/20221006061337_create_preview_card_trends.rb new file mode 100644 index 000000000..baad9c31c --- /dev/null +++ b/db/migrate/20221006061337_create_preview_card_trends.rb @@ -0,0 +1,11 @@ +class CreatePreviewCardTrends < ActiveRecord::Migration[6.1] + def change + create_table :preview_card_trends do |t| + t.references :preview_card, null: false, foreign_key: { on_delete: :cascade }, index: { unique: true } + t.float :score, null: false, default: 0 + t.integer :rank, null: false, default: 0 + t.boolean :allowed, null: false, default: false + t.string :language + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 1a98b22db..2f9058509 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_08_29_192658) do +ActiveRecord::Schema.define(version: 2022_10_06_061337) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -735,6 +735,15 @@ ActiveRecord::Schema.define(version: 2022_08_29_192658) do t.index ["domain"], name: "index_preview_card_providers_on_domain", unique: true end + create_table "preview_card_trends", force: :cascade do |t| + t.bigint "preview_card_id", null: false + t.float "score", default: 0.0, null: false + t.integer "rank", default: 0, null: false + t.boolean "allowed", default: false, null: false + t.string "language" + t.index ["preview_card_id"], name: "index_preview_card_trends_on_preview_card_id", unique: true + end + create_table "preview_cards", force: :cascade do |t| t.string "url", default: "", null: false t.string "title", default: "", null: false @@ -894,6 +903,17 @@ ActiveRecord::Schema.define(version: 2022_08_29_192658) do t.index ["status_id"], name: "index_status_stats_on_status_id", unique: true end + create_table "status_trends", force: :cascade do |t| + t.bigint "status_id", null: false + t.bigint "account_id", null: false + t.float "score", default: 0.0, null: false + t.integer "rank", default: 0, null: false + t.boolean "allowed", default: false, null: false + t.string "language" + t.index ["account_id"], name: "index_status_trends_on_account_id" + t.index ["status_id"], name: "index_status_trends_on_status_id", unique: true + end + create_table "statuses", id: :bigint, default: -> { "timestamp_id('statuses'::text)" }, force: :cascade do |t| t.string "uri" t.text "text", default: "", null: false @@ -1171,6 +1191,7 @@ ActiveRecord::Schema.define(version: 2022_08_29_192658) do add_foreign_key "poll_votes", "polls", on_delete: :cascade add_foreign_key "polls", "accounts", on_delete: :cascade add_foreign_key "polls", "statuses", on_delete: :cascade + add_foreign_key "preview_card_trends", "preview_cards", on_delete: :cascade add_foreign_key "report_notes", "accounts", on_delete: :cascade add_foreign_key "report_notes", "reports", on_delete: :cascade add_foreign_key "reports", "accounts", column: "action_taken_by_account_id", name: "fk_bca45b75fd", on_delete: :nullify @@ -1185,6 +1206,8 @@ ActiveRecord::Schema.define(version: 2022_08_29_192658) do add_foreign_key "status_pins", "accounts", name: "fk_d4cb435b62", on_delete: :cascade add_foreign_key "status_pins", "statuses", on_delete: :cascade add_foreign_key "status_stats", "statuses", on_delete: :cascade + add_foreign_key "status_trends", "accounts", on_delete: :cascade + add_foreign_key "status_trends", "statuses", on_delete: :cascade add_foreign_key "statuses", "accounts", column: "in_reply_to_account_id", name: "fk_c7fa917661", on_delete: :nullify add_foreign_key "statuses", "accounts", name: "fk_9bda1543f7", on_delete: :cascade add_foreign_key "statuses", "statuses", column: "in_reply_to_id", on_delete: :nullify diff --git a/spec/fabricators/account_fabricator.rb b/spec/fabricators/account_fabricator.rb index f1cce281c..205706532 100644 --- a/spec/fabricators/account_fabricator.rb +++ b/spec/fabricators/account_fabricator.rb @@ -11,4 +11,5 @@ Fabricator(:account) do suspended_at { |attrs| attrs[:suspended] ? Time.now.utc : nil } silenced_at { |attrs| attrs[:silenced] ? Time.now.utc : nil } user { |attrs| attrs[:domain].nil? ? Fabricate.build(:user, account: nil) : nil } + discoverable true end diff --git a/spec/mailers/previews/admin_mailer_preview.rb b/spec/mailers/previews/admin_mailer_preview.rb index 01436ba7a..0ec9e9882 100644 --- a/spec/mailers/previews/admin_mailer_preview.rb +++ b/spec/mailers/previews/admin_mailer_preview.rb @@ -8,7 +8,7 @@ class AdminMailerPreview < ActionMailer::Preview # Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_trends def new_trends - AdminMailer.new_trends(Account.first, PreviewCard.limit(3), Tag.limit(3), Status.where(reblog_of_id: nil).limit(3)) + AdminMailer.new_trends(Account.first, PreviewCard.joins(:trend).limit(3), Tag.limit(3), Status.joins(:trend).where(reblog_of_id: nil).limit(3)) end # Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_appeal diff --git a/spec/models/preview_card_trend_spec.rb b/spec/models/preview_card_trend_spec.rb new file mode 100644 index 000000000..c7ab6ed14 --- /dev/null +++ b/spec/models/preview_card_trend_spec.rb @@ -0,0 +1,4 @@ +require 'rails_helper' + +RSpec.describe PreviewCardTrend, type: :model do +end diff --git a/spec/models/status_trend_spec.rb b/spec/models/status_trend_spec.rb new file mode 100644 index 000000000..6b82204a6 --- /dev/null +++ b/spec/models/status_trend_spec.rb @@ -0,0 +1,4 @@ +require 'rails_helper' + +RSpec.describe StatusTrend, type: :model do +end diff --git a/spec/models/trends/statuses_spec.rb b/spec/models/trends/statuses_spec.rb index 9cc67acbe..5f338a65e 100644 --- a/spec/models/trends/statuses_spec.rb +++ b/spec/models/trends/statuses_spec.rb @@ -9,8 +9,8 @@ RSpec.describe Trends::Statuses do let!(:query) { subject.query } let!(:today) { at_time } - let!(:status1) { Fabricate(:status, text: 'Foo', trendable: true, created_at: today) } - let!(:status2) { Fabricate(:status, text: 'Bar', trendable: true, created_at: today) } + let!(:status1) { Fabricate(:status, text: 'Foo', language: 'en', trendable: true, created_at: today) } + let!(:status2) { Fabricate(:status, text: 'Bar', language: 'en', trendable: true, created_at: today) } before do 15.times { reblog(status1, today) } @@ -69,9 +69,9 @@ RSpec.describe Trends::Statuses do let!(:today) { at_time } let!(:yesterday) { today - 1.day } - let!(:status1) { Fabricate(:status, text: 'Foo', trendable: true, created_at: yesterday) } - let!(:status2) { Fabricate(:status, text: 'Bar', trendable: true, created_at: today) } - let!(:status3) { Fabricate(:status, text: 'Baz', trendable: true, created_at: today) } + let!(:status1) { Fabricate(:status, text: 'Foo', language: 'en', trendable: true, created_at: yesterday) } + let!(:status2) { Fabricate(:status, text: 'Bar', language: 'en', trendable: true, created_at: today) } + let!(:status3) { Fabricate(:status, text: 'Baz', language: 'en', trendable: true, created_at: today) } before do 13.times { reblog(status1, today) } @@ -95,10 +95,10 @@ RSpec.describe Trends::Statuses do it 'decays scores' do subject.refresh(today) - original_score = subject.score(status2.id) + original_score = status2.trend.score expect(original_score).to be_a Float subject.refresh(today + subject.options[:score_halflife]) - decayed_score = subject.score(status2.id) + decayed_score = status2.trend.reload.score expect(decayed_score).to be <= original_score / 2 end end -- cgit From 1bd00036c284bcafb419eaf80347ba49d1b491d9 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 13 Oct 2022 14:42:37 +0200 Subject: Change about page to be mounted in the web UI (#19345) --- app/controllers/about_controller.rb | 60 +- .../api/v1/instances/domain_blocks_controller.rb | 23 + .../instances/extended_descriptions_controller.rb | 18 + .../fonts/montserrat/Montserrat-Medium.ttf | Bin 192488 -> 0 bytes .../fonts/montserrat/Montserrat-Regular.ttf | Bin 191860 -> 0 bytes .../fonts/montserrat/Montserrat-Regular.woff | Bin 81244 -> 0 bytes .../fonts/montserrat/Montserrat-Regular.woff2 | Bin 61840 -> 0 bytes app/javascript/mastodon/actions/server.js | 61 ++ app/javascript/mastodon/components/image.js | 33 + .../mastodon/components/server_banner.js | 8 +- app/javascript/mastodon/components/skeleton.js | 4 +- app/javascript/mastodon/features/about/index.js | 195 ++++- .../mastodon/features/privacy_policy/index.js | 2 +- app/javascript/mastodon/features/report/rules.js | 2 +- .../mastodon/features/ui/components/link_footer.js | 2 +- app/javascript/mastodon/reducers/server.js | 46 +- app/javascript/styles/application.scss | 2 - app/javascript/styles/contrast/diff.scss | 4 - app/javascript/styles/fonts/montserrat.scss | 21 - app/javascript/styles/mastodon-light/diff.scss | 86 +- app/javascript/styles/mastodon/about.scss | 902 +-------------------- app/javascript/styles/mastodon/compact_header.scss | 34 - app/javascript/styles/mastodon/components.scss | 474 +++++++++-- app/javascript/styles/mastodon/containers.scss | 1 - app/javascript/styles/mastodon/dashboard.scss | 1 - app/javascript/styles/mastodon/forms.scss | 63 +- app/javascript/styles/mastodon/widgets.scss | 229 +----- app/models/domain_block.rb | 4 +- app/models/extended_description.rb | 15 + app/serializers/rest/domain_block_serializer.rb | 17 + .../rest/extended_description_serializer.rb | 13 + app/views/about/_domain_blocks.html.haml | 12 - app/views/about/more.html.haml | 96 --- app/views/about/show.html.haml | 4 + config/locales/en.yml | 28 +- config/routes.rb | 6 +- spec/controllers/about_controller_spec.rb | 8 +- 37 files changed, 900 insertions(+), 1574 deletions(-) create mode 100644 app/controllers/api/v1/instances/domain_blocks_controller.rb create mode 100644 app/controllers/api/v1/instances/extended_descriptions_controller.rb delete mode 100644 app/javascript/fonts/montserrat/Montserrat-Medium.ttf delete mode 100644 app/javascript/fonts/montserrat/Montserrat-Regular.ttf delete mode 100644 app/javascript/fonts/montserrat/Montserrat-Regular.woff delete mode 100644 app/javascript/fonts/montserrat/Montserrat-Regular.woff2 create mode 100644 app/javascript/mastodon/components/image.js delete mode 100644 app/javascript/styles/fonts/montserrat.scss delete mode 100644 app/javascript/styles/mastodon/compact_header.scss create mode 100644 app/models/extended_description.rb create mode 100644 app/serializers/rest/domain_block_serializer.rb create mode 100644 app/serializers/rest/extended_description_serializer.rb delete mode 100644 app/views/about/_domain_blocks.html.haml delete mode 100644 app/views/about/more.html.haml create mode 100644 app/views/about/show.html.haml (limited to 'app/controllers/api/v1') diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb index eae7de8c8..0fbc6a800 100644 --- a/app/controllers/about_controller.rb +++ b/app/controllers/about_controller.rb @@ -1,63 +1,11 @@ # frozen_string_literal: true class AboutController < ApplicationController - include RegistrationSpamConcern + include WebAppControllerConcern - layout 'public' + skip_before_action :require_functional! - before_action :require_open_federation!, only: [:more] - before_action :set_body_classes, only: :show - before_action :set_instance_presenter - before_action :set_expires_in, only: [:more] - - skip_before_action :require_functional!, only: [:more] - - def more - flash.now[:notice] = I18n.t('about.instance_actor_flash') if params[:instance_actor] - - toc_generator = TOCGenerator.new(@instance_presenter.extended_description) - - @rules = Rule.ordered - @contents = toc_generator.html - @table_of_contents = toc_generator.toc - @blocks = DomainBlock.with_user_facing_limitations.by_severity if display_blocks? - end - - helper_method :display_blocks? - helper_method :display_blocks_rationale? - helper_method :public_fetch_mode? - helper_method :new_user - - private - - def require_open_federation! - not_found if whitelist_mode? - end - - def display_blocks? - Setting.show_domain_blocks == 'all' || (Setting.show_domain_blocks == 'users' && user_signed_in?) - end - - def display_blocks_rationale? - Setting.show_domain_blocks_rationale == 'all' || (Setting.show_domain_blocks_rationale == 'users' && user_signed_in?) - end - - def new_user - User.new.tap do |user| - user.build_account - user.build_invite_request - end - end - - def set_instance_presenter - @instance_presenter = InstancePresenter.new - end - - def set_body_classes - @hide_navbar = true - end - - def set_expires_in - expires_in 0, public: true + def show + expires_in 0, public: true unless user_signed_in? end end diff --git a/app/controllers/api/v1/instances/domain_blocks_controller.rb b/app/controllers/api/v1/instances/domain_blocks_controller.rb new file mode 100644 index 000000000..37a6906fb --- /dev/null +++ b/app/controllers/api/v1/instances/domain_blocks_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class Api::V1::Instances::DomainBlocksController < Api::BaseController + skip_before_action :require_authenticated_user!, unless: :whitelist_mode? + + before_action :require_enabled_api! + before_action :set_domain_blocks + + def index + expires_in 3.minutes, public: true + render json: @domain_blocks, each_serializer: REST::DomainBlockSerializer, with_comment: (Setting.show_domain_blocks_rationale == 'all' || (Setting.show_domain_blocks_rationale == 'users' && user_signed_in?)) + end + + private + + def require_enabled_api! + head 404 unless Setting.show_domain_blocks == 'all' || (Setting.show_domain_blocks == 'users' && user_signed_in?) + end + + def set_domain_blocks + @domain_blocks = DomainBlock.with_user_facing_limitations.by_severity + end +end diff --git a/app/controllers/api/v1/instances/extended_descriptions_controller.rb b/app/controllers/api/v1/instances/extended_descriptions_controller.rb new file mode 100644 index 000000000..c72e16cff --- /dev/null +++ b/app/controllers/api/v1/instances/extended_descriptions_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Api::V1::Instances::ExtendedDescriptionsController < Api::BaseController + skip_before_action :require_authenticated_user!, unless: :whitelist_mode? + + before_action :set_extended_description + + def show + expires_in 3.minutes, public: true + render json: @extended_description, serializer: REST::ExtendedDescriptionSerializer + end + + private + + def set_extended_description + @extended_description = ExtendedDescription.current + end +end diff --git a/app/javascript/fonts/montserrat/Montserrat-Medium.ttf b/app/javascript/fonts/montserrat/Montserrat-Medium.ttf deleted file mode 100644 index 88d70b89c..000000000 Binary files a/app/javascript/fonts/montserrat/Montserrat-Medium.ttf and /dev/null differ diff --git a/app/javascript/fonts/montserrat/Montserrat-Regular.ttf b/app/javascript/fonts/montserrat/Montserrat-Regular.ttf deleted file mode 100644 index 29ca85d4a..000000000 Binary files a/app/javascript/fonts/montserrat/Montserrat-Regular.ttf and /dev/null differ diff --git a/app/javascript/fonts/montserrat/Montserrat-Regular.woff b/app/javascript/fonts/montserrat/Montserrat-Regular.woff deleted file mode 100644 index af3b5ec44..000000000 Binary files a/app/javascript/fonts/montserrat/Montserrat-Regular.woff and /dev/null differ diff --git a/app/javascript/fonts/montserrat/Montserrat-Regular.woff2 b/app/javascript/fonts/montserrat/Montserrat-Regular.woff2 deleted file mode 100644 index 3d75434dd..000000000 Binary files a/app/javascript/fonts/montserrat/Montserrat-Regular.woff2 and /dev/null differ diff --git a/app/javascript/mastodon/actions/server.js b/app/javascript/mastodon/actions/server.js index af8fef780..31d4aea10 100644 --- a/app/javascript/mastodon/actions/server.js +++ b/app/javascript/mastodon/actions/server.js @@ -5,6 +5,14 @@ export const SERVER_FETCH_REQUEST = 'Server_FETCH_REQUEST'; export const SERVER_FETCH_SUCCESS = 'Server_FETCH_SUCCESS'; export const SERVER_FETCH_FAIL = 'Server_FETCH_FAIL'; +export const EXTENDED_DESCRIPTION_REQUEST = 'EXTENDED_DESCRIPTION_REQUEST'; +export const EXTENDED_DESCRIPTION_SUCCESS = 'EXTENDED_DESCRIPTION_SUCCESS'; +export const EXTENDED_DESCRIPTION_FAIL = 'EXTENDED_DESCRIPTION_FAIL'; + +export const SERVER_DOMAIN_BLOCKS_FETCH_REQUEST = 'SERVER_DOMAIN_BLOCKS_FETCH_REQUEST'; +export const SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS = 'SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS'; +export const SERVER_DOMAIN_BLOCKS_FETCH_FAIL = 'SERVER_DOMAIN_BLOCKS_FETCH_FAIL'; + export const fetchServer = () => (dispatch, getState) => { dispatch(fetchServerRequest()); @@ -28,3 +36,56 @@ const fetchServerFail = error => ({ type: SERVER_FETCH_FAIL, error, }); + +export const fetchExtendedDescription = () => (dispatch, getState) => { + dispatch(fetchExtendedDescriptionRequest()); + + api(getState) + .get('/api/v1/instance/extended_description') + .then(({ data }) => dispatch(fetchExtendedDescriptionSuccess(data))) + .catch(err => dispatch(fetchExtendedDescriptionFail(err))); +}; + +const fetchExtendedDescriptionRequest = () => ({ + type: EXTENDED_DESCRIPTION_REQUEST, +}); + +const fetchExtendedDescriptionSuccess = description => ({ + type: EXTENDED_DESCRIPTION_SUCCESS, + description, +}); + +const fetchExtendedDescriptionFail = error => ({ + type: EXTENDED_DESCRIPTION_FAIL, + error, +}); + +export const fetchDomainBlocks = () => (dispatch, getState) => { + dispatch(fetchDomainBlocksRequest()); + + api(getState) + .get('/api/v1/instance/domain_blocks') + .then(({ data }) => dispatch(fetchDomainBlocksSuccess(true, data))) + .catch(err => { + if (err.response.status === 404) { + dispatch(fetchDomainBlocksSuccess(false, [])); + } else { + dispatch(fetchDomainBlocksFail(err)); + } + }); +}; + +const fetchDomainBlocksRequest = () => ({ + type: SERVER_DOMAIN_BLOCKS_FETCH_REQUEST, +}); + +const fetchDomainBlocksSuccess = (isAvailable, blocks) => ({ + type: SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS, + isAvailable, + blocks, +}); + +const fetchDomainBlocksFail = error => ({ + type: SERVER_DOMAIN_BLOCKS_FETCH_FAIL, + error, +}); diff --git a/app/javascript/mastodon/components/image.js b/app/javascript/mastodon/components/image.js new file mode 100644 index 000000000..6e81ddf08 --- /dev/null +++ b/app/javascript/mastodon/components/image.js @@ -0,0 +1,33 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Blurhash from './blurhash'; +import classNames from 'classnames'; + +export default class Image extends React.PureComponent { + + static propTypes = { + src: PropTypes.string, + srcSet: PropTypes.string, + blurhash: PropTypes.string, + className: PropTypes.string, + }; + + state = { + loaded: false, + }; + + handleLoad = () => this.setState({ loaded: true }); + + render () { + const { src, srcSet, blurhash, className } = this.props; + const { loaded } = this.state; + + return ( +
+ {blurhash && } + +
+ ); + } + +} diff --git a/app/javascript/mastodon/components/server_banner.js b/app/javascript/mastodon/components/server_banner.js index ae4e200c3..c2336e43d 100644 --- a/app/javascript/mastodon/components/server_banner.js +++ b/app/javascript/mastodon/components/server_banner.js @@ -7,13 +7,15 @@ import ShortNumber from 'mastodon/components/short_number'; import Skeleton from 'mastodon/components/skeleton'; import Account from 'mastodon/containers/account_container'; import { domain } from 'mastodon/initial_state'; +import Image from 'mastodon/components/image'; +import { Link } from 'react-router-dom'; const messages = defineMessages({ aboutActiveUsers: { id: 'server_banner.about_active_users', defaultMessage: 'People using this server during the last 30 days (Monthly Active Users)' }, }); const mapStateToProps = state => ({ - server: state.get('server'), + server: state.getIn(['server', 'server']), }); export default @connect(mapStateToProps) @@ -41,7 +43,7 @@ class ServerBanner extends React.PureComponent { {domain}, mastodon: Mastodon }} />
- {server.get('title')} +
{isLoading ? ( @@ -83,7 +85,7 @@ class ServerBanner extends React.PureComponent {
- +
); } diff --git a/app/javascript/mastodon/components/skeleton.js b/app/javascript/mastodon/components/skeleton.js index 09093e99c..6a17ffb26 100644 --- a/app/javascript/mastodon/components/skeleton.js +++ b/app/javascript/mastodon/components/skeleton.js @@ -4,8 +4,8 @@ import PropTypes from 'prop-types'; const Skeleton = ({ width, height }) => ; Skeleton.propTypes = { - width: PropTypes.number, - height: PropTypes.number, + width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), }; export default Skeleton; diff --git a/app/javascript/mastodon/features/about/index.js b/app/javascript/mastodon/features/about/index.js index bc8d3a41b..e9212565a 100644 --- a/app/javascript/mastodon/features/about/index.js +++ b/app/javascript/mastodon/features/about/index.js @@ -1,27 +1,214 @@ import React from 'react'; -import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import Column from 'mastodon/components/column'; import LinkFooter from 'mastodon/features/ui/components/link_footer'; import { Helmet } from 'react-helmet'; +import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'mastodon/actions/server'; +import Account from 'mastodon/containers/account_container'; +import Skeleton from 'mastodon/components/skeleton'; +import Icon from 'mastodon/components/icon'; +import classNames from 'classnames'; +import Image from 'mastodon/components/image'; 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.' }, + suspended: { id: 'about.domain_blocks.suspended.title', defaultMessage: 'Suspended' }, + suspendedExplanation: { id: 'about.domain_blocks.suspended.explanation', defaultMessage: 'No data from this server will be processed, stored or exchanged, making any interaction or communication with users from this server impossible.' }, }); -export default @injectIntl +const severityMessages = { + silence: { + title: messages.silenced, + explanation: messages.silencedExplanation, + }, + + suspend: { + title: messages.suspended, + explanation: messages.suspendedExplanation, + }, +}; + +const mapStateToProps = state => ({ + server: state.getIn(['server', 'server']), + extendedDescription: state.getIn(['server', 'extendedDescription']), + domainBlocks: state.getIn(['server', 'domainBlocks']), +}); + +class Section extends React.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}
+ )} +
+ ); + } + +} + +export default @connect(mapStateToProps) +@injectIntl class About extends React.PureComponent { static propTypes = { + server: ImmutablePropTypes.map, + extendedDescription: ImmutablePropTypes.map, + domainBlocks: ImmutablePropTypes.contains({ + isLoading: PropTypes.bool, + isAvailable: PropTypes.bool, + items: ImmutablePropTypes.list, + }), + dispatch: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, }; + componentDidMount () { + const { dispatch } = this.props; + dispatch(fetchServer()); + dispatch(fetchExtendedDescription()); + } + + handleDomainBlocksOpen = () => { + const { dispatch } = this.props; + dispatch(fetchDomainBlocks()); + } + render () { - const { intl } = this.props; + const { intl, server, extendedDescription, domainBlocks } = this.props; + const isLoading = server.get('isLoading'); return ( - +
+
+ `${value} ${key.replace('@', '')}`).join(', ')} className='about__header__hero' /> +

{isLoading ? : server.get('domain')}

+

Mastodon }} />

+
+ +
+
+

+ + +
+ +
+ +
+

+ + {isLoading ? : {server.getIn(['contact', 'email'])}} +
+
+ +
+ {extendedDescription.get('isLoading') ? ( + <> + +
+ +
+ +
+ + + ) : (extendedDescription.get('content')?.length > 0 ? ( +
+ ) : ( +

+ ))} +
+ +
+ {!isLoading && (server.get('rules').isEmpty() ? ( +

+ ) : ( +
    + {server.get('rules').map(rule => ( +
  1. + {rule.get('text')} +
  2. + ))} +
+ ))} +
+ +
+ {domainBlocks.get('isLoading') ? ( + <> + +
+ + + ) : (domainBlocks.get('isAvailable') ? ( + <> +

+ + + + + + + + + + + + {domainBlocks.get('items').map(block => ( + + + + + + ))} + +
{block.get('domain')}{intl.formatMessage(severityMessages[block.get('severity')].title)}{block.get('comment')}
+ + ) : ( +

+ ))} +
+ + +
{intl.formatMessage(messages.title)} diff --git a/app/javascript/mastodon/features/privacy_policy/index.js b/app/javascript/mastodon/features/privacy_policy/index.js index 5fbe340c0..eee4255f4 100644 --- a/app/javascript/mastodon/features/privacy_policy/index.js +++ b/app/javascript/mastodon/features/privacy_policy/index.js @@ -44,7 +44,7 @@ class PrivacyPolicy extends React.PureComponent {
diff --git a/app/javascript/mastodon/features/report/rules.js b/app/javascript/mastodon/features/report/rules.js index 2cb4a95b5..920da68d6 100644 --- a/app/javascript/mastodon/features/report/rules.js +++ b/app/javascript/mastodon/features/report/rules.js @@ -7,7 +7,7 @@ import Button from 'mastodon/components/button'; import Option from './components/option'; const mapStateToProps = state => ({ - rules: state.getIn(['server', 'rules']), + rules: state.getIn(['server', 'server', 'rules']), }); export default @connect(mapStateToProps) diff --git a/app/javascript/mastodon/features/ui/components/link_footer.js b/app/javascript/mastodon/features/ui/components/link_footer.js index c4ce2a985..cc3d83572 100644 --- a/app/javascript/mastodon/features/ui/components/link_footer.js +++ b/app/javascript/mastodon/features/ui/components/link_footer.js @@ -51,7 +51,7 @@ class LinkFooter extends React.PureComponent { const items = []; items.push(); - items.push(); + items.push(); items.push(); items.push(); items.push(); diff --git a/app/javascript/mastodon/reducers/server.js b/app/javascript/mastodon/reducers/server.js index 68131c6dd..db9f2b5e6 100644 --- a/app/javascript/mastodon/reducers/server.js +++ b/app/javascript/mastodon/reducers/server.js @@ -1,18 +1,52 @@ -import { SERVER_FETCH_REQUEST, SERVER_FETCH_SUCCESS, SERVER_FETCH_FAIL } from 'mastodon/actions/server'; -import { Map as ImmutableMap, fromJS } from 'immutable'; +import { + SERVER_FETCH_REQUEST, + SERVER_FETCH_SUCCESS, + SERVER_FETCH_FAIL, + EXTENDED_DESCRIPTION_REQUEST, + EXTENDED_DESCRIPTION_SUCCESS, + EXTENDED_DESCRIPTION_FAIL, + SERVER_DOMAIN_BLOCKS_FETCH_REQUEST, + SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS, + SERVER_DOMAIN_BLOCKS_FETCH_FAIL, +} from 'mastodon/actions/server'; +import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; const initialState = ImmutableMap({ - isLoading: true, + server: ImmutableMap({ + isLoading: true, + }), + + extendedDescription: ImmutableMap({ + isLoading: true, + }), + + domainBlocks: ImmutableMap({ + isLoading: true, + isAvailable: true, + items: ImmutableList(), + }), }); export default function server(state = initialState, action) { switch (action.type) { case SERVER_FETCH_REQUEST: - return state.set('isLoading', true); + return state.setIn(['server', 'isLoading'], true); case SERVER_FETCH_SUCCESS: - return fromJS(action.server).set('isLoading', false); + return state.set('server', fromJS(action.server)).setIn(['server', 'isLoading'], false); case SERVER_FETCH_FAIL: - return state.set('isLoading', false); + return state.setIn(['server', 'isLoading'], false); + case EXTENDED_DESCRIPTION_REQUEST: + return state.setIn(['extendedDescription', 'isLoading'], true); + case EXTENDED_DESCRIPTION_SUCCESS: + return state.set('extendedDescription', fromJS(action.description)).setIn(['extendedDescription', 'isLoading'], false); + case EXTENDED_DESCRIPTION_FAIL: + return state.setIn(['extendedDescription', 'isLoading'], false); + case SERVER_DOMAIN_BLOCKS_FETCH_REQUEST: + return state.setIn(['domainBlocks', 'isLoading'], true); + case SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS: + return state.setIn(['domainBlocks', 'items'], fromJS(action.blocks)).setIn(['domainBlocks', 'isLoading'], false).setIn(['domainBlocks', 'isAvailable'], action.isAvailable); + case SERVER_DOMAIN_BLOCKS_FETCH_FAIL: + return state.setIn(['domainBlocks', 'isLoading'], false); default: return state; } diff --git a/app/javascript/styles/application.scss b/app/javascript/styles/application.scss index bbea06195..e9f596e2f 100644 --- a/app/javascript/styles/application.scss +++ b/app/javascript/styles/application.scss @@ -2,7 +2,6 @@ @import 'mastodon/variables'; @import 'fonts/roboto'; @import 'fonts/roboto-mono'; -@import 'fonts/montserrat'; @import 'mastodon/reset'; @import 'mastodon/basics'; @@ -10,7 +9,6 @@ @import 'mastodon/containers'; @import 'mastodon/lists'; @import 'mastodon/footer'; -@import 'mastodon/compact_header'; @import 'mastodon/widgets'; @import 'mastodon/forms'; @import 'mastodon/accounts'; diff --git a/app/javascript/styles/contrast/diff.scss b/app/javascript/styles/contrast/diff.scss index 841ed6648..22f5bcc94 100644 --- a/app/javascript/styles/contrast/diff.scss +++ b/app/javascript/styles/contrast/diff.scss @@ -13,10 +13,6 @@ } } -.rich-formatting a, -.rich-formatting p a, -.rich-formatting li a, -.landing-page__short-description p a, .status__content a, .reply-indicator__content a { color: lighten($ui-highlight-color, 12%); diff --git a/app/javascript/styles/fonts/montserrat.scss b/app/javascript/styles/fonts/montserrat.scss deleted file mode 100644 index 170fe6542..000000000 --- a/app/javascript/styles/fonts/montserrat.scss +++ /dev/null @@ -1,21 +0,0 @@ -@font-face { - font-family: mastodon-font-display; - src: - local('Montserrat'), - url('../fonts/montserrat/Montserrat-Regular.woff2') format('woff2'), - url('../fonts/montserrat/Montserrat-Regular.woff') format('woff'), - url('../fonts/montserrat/Montserrat-Regular.ttf') format('truetype'); - font-weight: 400; - font-display: swap; - font-style: normal; -} - -@font-face { - font-family: mastodon-font-display; - src: - local('Montserrat Medium'), - url('../fonts/montserrat/Montserrat-Medium.ttf') format('truetype'); - font-weight: 500; - font-display: swap; - font-style: normal; -} diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss index 0bc6247ef..4b27e6b4f 100644 --- a/app/javascript/styles/mastodon-light/diff.scss +++ b/app/javascript/styles/mastodon-light/diff.scss @@ -36,6 +36,20 @@ html { border-top: 0; } +.column > .scrollable.about { + border-top: 1px solid lighten($ui-base-color, 8%); +} + +.about__meta, +.about__section__title { + background: $white; + border: 1px solid lighten($ui-base-color, 8%); +} + +.rules-list li::before { + background: $ui-highlight-color; +} + .directory__card__img { background: lighten($ui-base-color, 12%); } @@ -45,10 +59,6 @@ html { border-bottom: 1px solid lighten($ui-base-color, 8%); } -.table-of-contents { - border: 1px solid lighten($ui-base-color, 8%); -} - .column-back-button, .column-header { background: $white; @@ -138,11 +148,6 @@ html { .compose-form__poll-wrapper select, .search__input, .setting-text, -.box-widget input[type="text"], -.box-widget input[type="email"], -.box-widget input[type="password"], -.box-widget textarea, -.statuses-grid .detailed-status, .report-dialog-modal__textarea, .audio-player { border: 1px solid lighten($ui-base-color, 8%); @@ -480,52 +485,16 @@ html { background: $white; } -.tabs-bar { - background: $white; - border: 1px solid lighten($ui-base-color, 8%); - border-bottom: 0; - - @media screen and (max-width: $no-gap-breakpoint) { - border-top: 0; - } - - &__link { - padding-bottom: 14px; - border-bottom-width: 1px; - border-bottom-color: lighten($ui-base-color, 8%); - - &:hover, - &:active, - &:focus { - background: $ui-base-color; - } - - &.active { - &:hover, - &:active, - &:focus { - background: transparent; - border-bottom-color: $ui-highlight-color; - } - } - } -} - // Change the default colors used on some parts of the profile pages .activity-stream-tabs { background: $account-background-color; border-bottom-color: lighten($ui-base-color, 8%); } -.box-widget, .nothing-here, .page-header, .directory__tag > a, -.directory__tag > div, -.landing-page__call-to-action, -.contact-widget, -.landing .hero-widget__text, -.landing-page__information.contact-widget { +.directory__tag > div { background: $white; border: 1px solid lighten($ui-base-color, 8%); @@ -536,11 +505,6 @@ html { } } -.landing .hero-widget__text { - border-top: 0; - border-bottom: 0; -} - .simple_form { input[type="text"], input[type="number"], @@ -553,26 +517,12 @@ html { } } -.landing .hero-widget__footer { - background: $white; - border: 1px solid lighten($ui-base-color, 8%); - border-top: 0; - - @media screen and (max-width: $no-gap-breakpoint) { - border: 0; - } -} - .picture-in-picture-placeholder { background: $white; border-color: lighten($ui-base-color, 8%); color: lighten($ui-base-color, 8%); } -.brand__tagline { - color: $ui-secondary-color; -} - .directory__tag > a { &:hover, &:active, @@ -666,8 +616,7 @@ html { } } -.simple_form, -.table-form { +.simple_form { .warning { box-shadow: none; background: rgba($error-red, 0.5); @@ -801,9 +750,6 @@ html { } .hero-widget, -.box-widget, -.contact-widget, -.landing-page__information.contact-widget, .moved-account-widget, .memoriam-widget, .activity-stream, diff --git a/app/javascript/styles/mastodon/about.scss b/app/javascript/styles/mastodon/about.scss index 8893e3cf0..0183c43d5 100644 --- a/app/javascript/styles/mastodon/about.scss +++ b/app/javascript/styles/mastodon/about.scss @@ -1,7 +1,5 @@ $maximum-width: 1235px; $fluid-breakpoint: $maximum-width + 20px; -$column-breakpoint: 700px; -$small-breakpoint: 960px; .container { box-sizing: border-box; @@ -15,892 +13,44 @@ $small-breakpoint: 960px; } } -.rich-formatting { - font-family: $font-sans-serif, sans-serif; - font-size: 14px; - font-weight: 400; - line-height: 1.7; - word-wrap: break-word; - color: $darker-text-color; - - a { - color: $highlight-text-color; - text-decoration: underline; - - &:hover, - &:focus, - &:active { - text-decoration: none; - } - } - - p, - li { - color: $darker-text-color; - } - - p { - margin-top: 0; - margin-bottom: 0.85em; - - &:last-child { - margin-bottom: 0; - } - } - - strong { - font-weight: 700; - color: $secondary-text-color; - } - - em { - font-style: italic; - color: $secondary-text-color; - } - - code { - font-size: 0.85em; - background: darken($ui-base-color, 8%); - border-radius: 4px; - padding: 0.2em 0.3em; - } - - h1, - h2, - h3, - h4, - h5, - h6 { - font-family: $font-display, sans-serif; - margin-top: 1.275em; - margin-bottom: 0.85em; - font-weight: 500; - color: $secondary-text-color; - } - - h1 { - font-size: 2em; - } - - h2 { - font-size: 1.75em; - } - - h3 { - font-size: 1.5em; - } - - h4 { - font-size: 1.25em; - } - - h5, - h6 { - font-size: 1em; - } - - ul { - list-style: disc; - } - - ol { - list-style: decimal; - } - - ul, - ol { - margin: 0; - padding: 0; - padding-left: 2em; - margin-bottom: 0.85em; - - &[type='a'] { - list-style-type: lower-alpha; - } - - &[type='i'] { - list-style-type: lower-roman; - } - } - - hr { - width: 100%; - height: 0; - border: 0; - border-bottom: 1px solid lighten($ui-base-color, 4%); - margin: 1.7em 0; - - &.spacer { - height: 1px; - border: 0; - } - } - - table { - width: 100%; - border-collapse: collapse; - break-inside: auto; - margin-top: 24px; - margin-bottom: 32px; - - thead tr, - tbody tr { - border-bottom: 1px solid lighten($ui-base-color, 4%); - font-size: 1em; - line-height: 1.625; - font-weight: 400; - text-align: left; - color: $darker-text-color; - } - - thead tr { - border-bottom-width: 2px; - line-height: 1.5; - font-weight: 500; - color: $dark-text-color; - } - - th, - td { - padding: 8px; - align-self: flex-start; - align-items: flex-start; - word-break: break-all; - - &.nowrap { - width: 25%; - position: relative; - - &::before { - content: ' '; - visibility: hidden; - } - - span { - position: absolute; - left: 8px; - right: 8px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - } - } - } - - & > :first-child { - margin-top: 0; - } +.brand { + position: relative; + text-decoration: none; } -.information-board { - background: darken($ui-base-color, 4%); - padding: 20px 0; - - .container-alt { - position: relative; - padding-right: 280px + 15px; - } - - &__sections { - display: flex; - justify-content: space-between; - flex-wrap: wrap; - } - - &__section { - flex: 1 0 0; - font-family: $font-sans-serif, sans-serif; - font-size: 16px; - line-height: 28px; - color: $primary-text-color; - text-align: right; - padding: 10px 15px; - - span, - strong { - display: block; - } - - span { - &:last-child { - color: $secondary-text-color; - } - } - - strong { - font-family: $font-display, sans-serif; - font-weight: 500; - font-size: 32px; - line-height: 48px; - } - - @media screen and (max-width: $column-breakpoint) { - text-align: center; - } - } - - .panel { - position: absolute; - width: 280px; - box-sizing: border-box; - background: darken($ui-base-color, 8%); - padding: 20px; - padding-top: 10px; - border-radius: 4px 4px 0 0; - right: 0; - bottom: -40px; - - .panel-header { - font-family: $font-display, sans-serif; - font-size: 14px; - line-height: 24px; - font-weight: 500; - color: $darker-text-color; - padding-bottom: 5px; - margin-bottom: 15px; - border-bottom: 1px solid lighten($ui-base-color, 4%); - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - - a, - span { - font-weight: 400; - color: darken($darker-text-color, 10%); - } - - a { - text-decoration: none; - } - } - } - - .owner { - text-align: center; - - .avatar { - width: 80px; - height: 80px; - margin: 0 auto; - margin-bottom: 15px; - - img { - display: block; - width: 80px; - height: 80px; - border-radius: 48px; - } - } - - .name { - font-size: 14px; - - a { - display: block; - color: $primary-text-color; - text-decoration: none; - - &:hover { - .display_name { - text-decoration: underline; - } - } - } - - .username { - display: block; - color: $darker-text-color; - } - } - } -} +.rules-list { + font-size: 15px; + line-height: 22px; + color: $primary-text-color; + counter-reset: list-counter; -.landing-page { - p, li { - font-family: $font-sans-serif, sans-serif; - font-size: 16px; - font-weight: 400; - line-height: 30px; - margin-bottom: 12px; - color: $darker-text-color; - - a { - color: $highlight-text-color; - text-decoration: underline; - } - } - - em { - display: inline; - margin: 0; - padding: 0; - font-weight: 700; - background: transparent; - font-family: inherit; - font-size: inherit; - line-height: inherit; - color: lighten($darker-text-color, 10%); - } - - h1 { - font-family: $font-display, sans-serif; - font-size: 26px; - line-height: 30px; - font-weight: 500; - margin-bottom: 20px; - color: $secondary-text-color; - - small { - font-family: $font-sans-serif, sans-serif; - display: block; - font-size: 18px; - font-weight: 400; - color: lighten($darker-text-color, 10%); - } - } - - h2 { - font-family: $font-display, sans-serif; - font-size: 22px; - line-height: 26px; - font-weight: 500; - margin-bottom: 20px; - color: $secondary-text-color; - } - - h3 { - font-family: $font-display, sans-serif; - font-size: 18px; - line-height: 24px; - font-weight: 500; - margin-bottom: 20px; - color: $secondary-text-color; - } - - h4 { - font-family: $font-display, sans-serif; - font-size: 16px; - line-height: 24px; - font-weight: 500; - margin-bottom: 20px; - color: $secondary-text-color; - } - - h5 { - font-family: $font-display, sans-serif; - font-size: 14px; - line-height: 24px; - font-weight: 500; - margin-bottom: 20px; - color: $secondary-text-color; - } - - h6 { - font-family: $font-display, sans-serif; - font-size: 12px; - line-height: 24px; + position: relative; + border-bottom: 1px solid lighten($ui-base-color, 8%); + padding: 1em 1.75em; + padding-left: 3em; font-weight: 500; - margin-bottom: 20px; - color: $secondary-text-color; - } - - ul, - ol { - margin-left: 20px; - - &[type='a'] { - list-style-type: lower-alpha; - } - - &[type='i'] { - list-style-type: lower-roman; - } - } - - ul { - list-style: disc; - } - - ol { - list-style: decimal; - } - - li > ol, - li > ul { - margin-top: 6px; - } - - hr { - width: 100%; - height: 0; - border: 0; - border-bottom: 1px solid rgba($ui-base-lighter-color, 0.6); - margin: 20px 0; - - &.spacer { - height: 1px; - border: 0; - } - } - - &__information, - &__forms { - padding: 20px; - } - - &__call-to-action { - background: $ui-base-color; - border-radius: 4px; - padding: 25px 40px; - overflow: hidden; - box-sizing: border-box; - - .row { - width: 100%; - display: flex; - flex-direction: row-reverse; - flex-wrap: nowrap; - justify-content: space-between; - align-items: center; - } - - .row__information-board { - display: flex; - justify-content: flex-end; - align-items: flex-end; - - .information-board__section { - flex: 1 0 auto; - padding: 0 10px; - } - - @media screen and (max-width: $no-gap-breakpoint) { - width: 100%; - justify-content: space-between; - } - } - - .row__mascot { - flex: 1; - margin: 10px -50px 0 0; - - @media screen and (max-width: $no-gap-breakpoint) { - display: none; - } - } - } - - &__logo { - margin-right: 20px; - - img { - height: 50px; - width: auto; - mix-blend-mode: lighten; - } - } - - &__information { - padding: 45px 40px; - margin-bottom: 10px; - - &:last-child { - margin-bottom: 0; - } - - strong { + counter-increment: list-counter; + + &::before { + content: counter(list-counter); + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + background: $highlight-text-color; + color: $ui-base-color; + border-radius: 50%; + width: 4ch; + height: 4ch; font-weight: 500; - color: lighten($darker-text-color, 10%); - } - - .account { - border-bottom: 0; - padding: 0; - - &__display-name { - align-items: center; - display: flex; - margin-right: 5px; - } - - div.account__display-name { - &:hover { - .display-name strong { - text-decoration: none; - } - } - - .account__avatar { - cursor: default; - } - } - - &__avatar-wrapper { - margin-left: 0; - flex: 0 0 auto; - } - - .display-name { - font-size: 15px; - - &__account { - font-size: 14px; - } - } - } - - @media screen and (max-width: $small-breakpoint) { - .contact { - margin-top: 30px; - } - } - - @media screen and (max-width: $column-breakpoint) { - padding: 25px 20px; - } - } - - &__information, - &__forms, - #mastodon-timeline { - box-sizing: border-box; - background: $ui-base-color; - border-radius: 4px; - box-shadow: 0 0 6px rgba($black, 0.1); - } - - &__mascot { - height: 104px; - position: relative; - left: -40px; - bottom: 25px; - - img { - height: 190px; - width: auto; - } - } - - &__short-description { - .row { display: flex; - flex-wrap: wrap; + justify-content: center; align-items: center; - margin-bottom: 40px; - } - - @media screen and (max-width: $column-breakpoint) { - .row { - margin-bottom: 20px; - } - } - - p a { - color: $secondary-text-color; - } - - h1 { - font-weight: 500; - color: $primary-text-color; - margin-bottom: 0; - - small { - color: $darker-text-color; - - span { - color: $secondary-text-color; - } - } - } - - p:last-child { - margin-bottom: 0; - } - } - - &__hero { - margin-bottom: 10px; - - img { - display: block; - margin: 0; - max-width: 100%; - height: auto; - border-radius: 4px; - } - } - - @media screen and (max-width: 840px) { - .information-board { - .container-alt { - padding-right: 20px; - } - - .panel { - position: static; - margin-top: 20px; - width: 100%; - border-radius: 4px; - - .panel-header { - text-align: center; - } - } - } - } - - @media screen and (max-width: 675px) { - .header-wrapper { - padding-top: 0; - - &.compact { - padding-bottom: 0; - } - - &.compact .hero .heading { - text-align: initial; - } } - .header .container-alt, - .features .container-alt { - display: block; - } - } - - .cta { - margin: 20px; - } -} - -.landing { - margin-bottom: 100px; - - @media screen and (max-width: 738px) { - margin-bottom: 0; - } - - &__brand { - display: flex; - justify-content: center; - align-items: center; - padding: 50px; - - .logo { - fill: $primary-text-color; - height: 52px; - } - - @media screen and (max-width: $no-gap-breakpoint) { - padding: 0; - margin-bottom: 30px; - } - } - - .directory { - margin-top: 30px; - background: transparent; - box-shadow: none; - border-radius: 0; - } - - .hero-widget { - margin-top: 30px; - margin-bottom: 0; - - h4 { - padding: 10px; - text-transform: uppercase; - font-weight: 700; - font-size: 13px; - color: $darker-text-color; - } - - &__text { - border-radius: 0; - padding-bottom: 0; - } - - &__footer { - background: $ui-base-color; - padding: 10px; - border-radius: 0 0 4px 4px; - display: flex; - - &__column { - flex: 1 1 50%; - overflow-x: hidden; - } - } - - .account { - padding: 10px 0; - border-bottom: 0; - - .account__display-name { - display: flex; - align-items: center; - } - } - - &__counters__wrapper { - display: flex; - } - - &__counter { - padding: 10px; - width: 50%; - - strong { - font-family: $font-display, sans-serif; - font-size: 15px; - font-weight: 700; - display: block; - } - - span { - font-size: 14px; - color: $darker-text-color; - } - } - } - - .simple_form .user_agreement .label_input > label { - font-weight: 400; - color: $darker-text-color; - } - - .simple_form p.lead { - color: $darker-text-color; - font-size: 15px; - line-height: 20px; - font-weight: 400; - margin-bottom: 25px; - } - - &__grid { - max-width: 960px; - margin: 0 auto; - display: grid; - grid-template-columns: minmax(0, 50%) minmax(0, 50%); - grid-gap: 30px; - - @media screen and (max-width: 738px) { - grid-template-columns: minmax(0, 100%); - grid-gap: 10px; - - &__column-login { - grid-row: 1; - display: flex; - flex-direction: column; - - .box-widget { - order: 2; - flex: 0 0 auto; - } - - .hero-widget { - margin-top: 0; - margin-bottom: 10px; - order: 1; - flex: 0 0 auto; - } - } - - &__column-registration { - grid-row: 2; - } - - .directory { - margin-top: 10px; - } - } - - @media screen and (max-width: $no-gap-breakpoint) { - grid-gap: 0; - - .hero-widget { - display: block; - margin-bottom: 0; - box-shadow: none; - - &__img, - &__img img, - &__footer { - border-radius: 0; - } - } - - .hero-widget, - .box-widget, - .directory__tag { - border-bottom: 1px solid lighten($ui-base-color, 8%); - } - - .directory { - margin-top: 0; - - &__tag { - margin-bottom: 0; - - & > a, - & > div { - border-radius: 0; - box-shadow: none; - } - - &:last-child { - border-bottom: 0; - } - } - } - } - } -} - -.brand { - position: relative; - text-decoration: none; -} - -.brand__tagline { - display: block; - position: absolute; - bottom: -10px; - left: 50px; - width: 300px; - color: $ui-primary-color; - text-decoration: none; - font-size: 14px; - - @media screen and (max-width: $no-gap-breakpoint) { - position: static; - width: auto; - margin-top: 20px; - color: $dark-text-color; - } -} - -.rules-list { - background: darken($ui-base-color, 2%); - border: 1px solid darken($ui-base-color, 8%); - border-radius: 4px; - padding: 0.5em 2.5em !important; - margin-top: 1.85em !important; - - li { - border-bottom: 1px solid lighten($ui-base-color, 4%); - color: $dark-text-color; - padding: 1em; - &:last-child { border-bottom: 0; } } - - &__text { - color: $primary-text-color; - } } diff --git a/app/javascript/styles/mastodon/compact_header.scss b/app/javascript/styles/mastodon/compact_header.scss deleted file mode 100644 index 4980ab5f1..000000000 --- a/app/javascript/styles/mastodon/compact_header.scss +++ /dev/null @@ -1,34 +0,0 @@ -.compact-header { - h1 { - font-size: 24px; - line-height: 28px; - color: $darker-text-color; - font-weight: 500; - margin-bottom: 20px; - padding: 0 10px; - word-wrap: break-word; - - @media screen and (max-width: 740px) { - text-align: center; - padding: 20px 10px 0; - } - - a { - color: inherit; - text-decoration: none; - } - - small { - font-weight: 400; - color: $secondary-text-color; - } - - img { - display: inline-block; - margin-bottom: -5px; - margin-right: 15px; - width: 36px; - height: 36px; - } - } -} diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index a8919b9cb..d4657d180 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -3261,6 +3261,7 @@ $ui-header-height: 55px; padding: 10px; padding-top: 20px; z-index: 1; + font-size: 13px; ul { margin-bottom: 10px; @@ -3272,7 +3273,6 @@ $ui-header-height: 55px; p { color: $dark-text-color; - font-size: 13px; margin-bottom: 20px; a { @@ -8266,79 +8266,202 @@ noscript { &__body { margin-top: 20px; - color: $secondary-text-color; - font-size: 15px; - line-height: 22px; + } +} - h1, - p, - ul, - ol { - margin-bottom: 20px; - } +.prose { + color: $secondary-text-color; + font-size: 15px; + line-height: 22px; - ul { - list-style: disc; - } + p, + ul, + ol { + margin-top: 1.25em; + margin-bottom: 1.25em; + } - ol { - list-style: decimal; - } + img { + margin-top: 2em; + margin-bottom: 2em; + } - ul, - ol { - padding-left: 1em; + video { + margin-top: 2em; + margin-bottom: 2em; + } + + figure { + margin-top: 2em; + margin-bottom: 2em; + + figcaption { + font-size: 0.875em; + line-height: 1.4285714; + margin-top: 0.8571429em; } + } - li { - margin-bottom: 10px; + figure > * { + margin-top: 0; + margin-bottom: 0; + } - &::marker { - color: $darker-text-color; - } + h1 { + font-size: 1.5em; + margin-top: 0; + margin-bottom: 1em; + line-height: 1.33; + } - &:last-child { - margin-bottom: 0; - } - } + h2 { + font-size: 1.25em; + margin-top: 1.6em; + margin-bottom: 0.6em; + line-height: 1.6; + } + + h3, + h4, + h5, + h6 { + margin-top: 1.5em; + margin-bottom: 0.5em; + line-height: 1.5; + } - h1 { - color: $primary-text-color; - font-size: 19px; - line-height: 24px; - font-weight: 700; - margin-top: 30px; + ol { + counter-reset: list-counter; + } - &:first-child { - margin-top: 0; - } - } + li { + margin-top: 0.5em; + margin-bottom: 0.5em; + } - strong { - font-weight: 700; - color: $primary-text-color; - } + ol > li { + counter-increment: list-counter; - em { - font-style: italic; + &::before { + content: counter(list-counter) "."; + position: absolute; + left: 0; } + } - a { - color: $highlight-text-color; - text-decoration: underline; + ul > li::before { + content: ""; + position: absolute; + background-color: $darker-text-color; + border-radius: 50%; + width: 0.375em; + height: 0.375em; + top: 0.5em; + left: 0.25em; + } - &:focus, - &:hover, - &:active { - text-decoration: none; - } - } + ul > li, + ol > li { + position: relative; + padding-left: 1.75em; + } - hr { - border: 1px solid lighten($ui-base-color, 4%); - margin: 30px 0; + & > ul > li p { + margin-top: 0.75em; + margin-bottom: 0.75em; + } + + & > ul > li > *:first-child { + margin-top: 1.25em; + } + + & > ul > li > *:last-child { + margin-bottom: 1.25em; + } + + & > ol > li > *:first-child { + margin-top: 1.25em; + } + + & > ol > li > *:last-child { + margin-bottom: 1.25em; + } + + ul ul, + ul ol, + ol ul, + ol ol { + margin-top: 0.75em; + margin-bottom: 0.75em; + } + + h1, + h2, + h3, + h4, + h5, + h6, + strong, + b { + color: $primary-text-color; + font-weight: 700; + } + + em, + i { + font-style: italic; + } + + a { + color: $highlight-text-color; + text-decoration: underline; + + &:focus, + &:hover, + &:active { + text-decoration: none; } } + + code { + font-size: 0.875em; + background: darken($ui-base-color, 8%); + border-radius: 4px; + padding: 0.2em 0.3em; + } + + hr { + border: 0; + border-top: 1px solid lighten($ui-base-color, 4%); + margin-top: 3em; + margin-bottom: 3em; + } + + hr + * { + margin-top: 0; + } + + h2 + * { + margin-top: 0; + } + + h3 + * { + margin-top: 0; + } + + h4 + *, + h5 + *, + h6 + * { + margin-top: 0; + } + + & > :first-child { + margin-top: 0; + } + + & > :last-child { + margin-bottom: 0; + } } .dismissable-banner { @@ -8365,3 +8488,242 @@ noscript { justify-content: center; } } + +.image { + position: relative; + overflow: hidden; + + &__preview { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; + } + + &.loaded &__preview { + display: none; + } + + img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + border: 0; + background: transparent; + opacity: 0; + } + + &.loaded img { + opacity: 1; + } +} + +.about { + padding: 20px; + + @media screen and (min-width: $no-gap-breakpoint) { + border-radius: 4px; + } + + &__header { + margin-bottom: 30px; + + &__hero { + width: 100%; + height: auto; + aspect-ratio: 1.9; + background: lighten($ui-base-color, 4%); + border-radius: 8px; + margin-bottom: 30px; + } + + h1, + p { + text-align: center; + } + + h1 { + font-size: 24px; + line-height: 1.5; + font-weight: 700; + margin-bottom: 10px; + } + + p { + font-size: 16px; + line-height: 24px; + font-weight: 400; + color: $darker-text-color; + } + } + + &__meta { + background: lighten($ui-base-color, 4%); + border-radius: 4px; + display: flex; + margin-bottom: 30px; + font-size: 15px; + + &__column { + box-sizing: border-box; + width: 50%; + padding: 20px; + } + + &__divider { + width: 0; + border: 0; + border-style: solid; + border-color: lighten($ui-base-color, 8%); + border-left-width: 1px; + min-height: calc(100% - 60px); + flex: 0 0 auto; + } + + h4 { + font-size: 15px; + text-transform: uppercase; + color: $darker-text-color; + font-weight: 500; + margin-bottom: 20px; + } + + @media screen and (max-width: 600px) { + display: block; + + h4 { + text-align: center; + } + + &__column { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + } + + &__divider { + min-height: 0; + width: 100%; + border-left-width: 0; + border-top-width: 1px; + } + } + + .layout-multiple-columns & { + display: block; + + h4 { + text-align: center; + } + + &__column { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + } + + &__divider { + min-height: 0; + width: 100%; + border-left-width: 0; + border-top-width: 1px; + } + } + } + + &__mail { + color: $primary-text-color; + text-decoration: none; + font-weight: 500; + + &:hover, + &:focus, + &:active { + text-decoration: underline; + } + } + + .getting-started__footer { + padding: 0; + margin-top: 60px; + text-align: center; + font-size: 15px; + line-height: 22px; + + @media screen and (min-width: $no-gap-breakpoint) { + display: none; + } + } + + .account { + padding: 0; + border: 0; + } + + .account__avatar-wrapper { + margin-left: 0; + } + + .account__relationship { + display: none; + } + + &__section { + margin-bottom: 10px; + + &__title { + font-size: 17px; + font-weight: 600; + line-height: 22px; + padding: 20px; + border-radius: 4px; + background: lighten($ui-base-color, 4%); + color: $highlight-text-color; + cursor: pointer; + } + + &.active &__title { + border-radius: 4px 4px 0 0; + } + + &__body { + border: 1px solid lighten($ui-base-color, 4%); + border-top: 0; + padding: 20px; + font-size: 15px; + line-height: 22px; + } + } + + &__domain-blocks { + margin-top: 30px; + width: 100%; + border-collapse: collapse; + break-inside: auto; + + th { + text-align: left; + font-weight: 500; + color: $darker-text-color; + } + + thead tr, + tbody tr { + border-bottom: 1px solid lighten($ui-base-color, 8%); + } + + tbody tr:last-child { + border-bottom: 0; + } + + th, + td { + padding: 8px; + } + } +} diff --git a/app/javascript/styles/mastodon/containers.scss b/app/javascript/styles/mastodon/containers.scss index 01ee56219..8e5ed03f0 100644 --- a/app/javascript/styles/mastodon/containers.scss +++ b/app/javascript/styles/mastodon/containers.scss @@ -30,7 +30,6 @@ outline: 0; padding: 12px 16px; line-height: 32px; - font-family: $font-display, sans-serif; font-weight: 500; font-size: 14px; } diff --git a/app/javascript/styles/mastodon/dashboard.scss b/app/javascript/styles/mastodon/dashboard.scss index c21fc9eba..f25765d1d 100644 --- a/app/javascript/styles/mastodon/dashboard.scss +++ b/app/javascript/styles/mastodon/dashboard.scss @@ -38,7 +38,6 @@ font-weight: 500; font-size: 24px; color: $primary-text-color; - font-family: $font-display, sans-serif; margin-bottom: 20px; line-height: 30px; } diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index 3d67f3b56..69a0b22d6 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -139,18 +139,9 @@ code { } .rules-list { - list-style: decimal; font-size: 17px; line-height: 22px; - font-weight: 500; - background: transparent; - border: 0; - padding: 0.5em 1em !important; margin-bottom: 30px; - - li { - border-color: lighten($ui-base-color, 8%); - } } .hint { @@ -603,41 +594,6 @@ code { } } } - - &__overlay-area { - position: relative; - - &__blurred form { - filter: blur(2px); - } - - &__overlay { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - display: flex; - justify-content: center; - align-items: center; - background: rgba($ui-base-color, 0.65); - border-radius: 4px; - margin-left: -4px; - margin-top: -4px; - padding: 4px; - - &__content { - text-align: center; - - &.rich-formatting { - &, - p { - color: $primary-text-color; - } - } - } - } - } } .block-icon { @@ -908,24 +864,7 @@ code { } } -.table-form { - p { - margin-bottom: 15px; - - strong { - font-weight: 500; - - @each $lang in $cjk-langs { - &:lang(#{$lang}) { - font-weight: 700; - } - } - } - } -} - -.simple_form, -.table-form { +.simple_form { .warning { box-sizing: border-box; background: rgba($error-value-color, 0.5); diff --git a/app/javascript/styles/mastodon/widgets.scss b/app/javascript/styles/mastodon/widgets.scss index 43284eb48..260a97c6d 100644 --- a/app/javascript/styles/mastodon/widgets.scss +++ b/app/javascript/styles/mastodon/widgets.scss @@ -112,13 +112,6 @@ } } -.box-widget { - padding: 20px; - border-radius: 4px; - background: $ui-base-color; - box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); -} - .placeholder-widget { padding: 16px; border-radius: 4px; @@ -128,47 +121,6 @@ margin-bottom: 10px; } -.contact-widget { - min-height: 100%; - font-size: 15px; - color: $darker-text-color; - line-height: 20px; - word-wrap: break-word; - font-weight: 400; - padding: 0; - - h4 { - padding: 10px; - text-transform: uppercase; - font-weight: 700; - font-size: 13px; - color: $darker-text-color; - } - - .account { - border-bottom: 0; - padding: 10px 0; - padding-top: 5px; - } - - & > a { - display: inline-block; - padding: 10px; - padding-top: 0; - color: $darker-text-color; - text-decoration: none; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - - &:hover, - &:focus, - &:active { - text-decoration: underline; - } - } -} - .moved-account-widget { padding: 15px; padding-bottom: 20px; @@ -249,37 +201,6 @@ margin-bottom: 10px; } -.page-header { - background: lighten($ui-base-color, 8%); - box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); - border-radius: 4px; - padding: 60px 15px; - text-align: center; - margin: 10px 0; - - h1 { - color: $primary-text-color; - font-size: 36px; - line-height: 1.1; - font-weight: 700; - margin-bottom: 10px; - } - - p { - font-size: 15px; - color: $darker-text-color; - } - - @media screen and (max-width: $no-gap-breakpoint) { - margin-top: 0; - background: lighten($ui-base-color, 4%); - - h1 { - font-size: 24px; - } - } -} - .directory { background: $ui-base-color; border-radius: 4px; @@ -366,34 +287,6 @@ } } -.avatar-stack { - display: flex; - justify-content: flex-end; - - .account__avatar { - flex: 0 0 auto; - width: 36px; - height: 36px; - border-radius: 50%; - position: relative; - margin-left: -10px; - background: darken($ui-base-color, 8%); - border: 2px solid $ui-base-color; - - &:nth-child(1) { - z-index: 1; - } - - &:nth-child(2) { - z-index: 2; - } - - &:nth-child(3) { - z-index: 3; - } - } -} - .accounts-table { width: 100%; @@ -495,11 +388,7 @@ .moved-account-widget, .memoriam-widget, -.box-widget, -.contact-widget, -.landing-page__information.contact-widget, -.directory, -.page-header { +.directory { @media screen and (max-width: $no-gap-breakpoint) { margin-bottom: 0; box-shadow: none; @@ -507,88 +396,6 @@ } } -$maximum-width: 1235px; -$fluid-breakpoint: $maximum-width + 20px; - -.statuses-grid { - min-height: 600px; - - @media screen and (max-width: 640px) { - width: 100% !important; // Masonry layout is unnecessary at this width - } - - &__item { - width: math.div(960px - 20px, 3); - - @media screen and (max-width: $fluid-breakpoint) { - width: math.div(940px - 20px, 3); - } - - @media screen and (max-width: 640px) { - width: 100%; - } - - @media screen and (max-width: $no-gap-breakpoint) { - width: 100vw; - } - } - - .detailed-status { - border-radius: 4px; - - @media screen and (max-width: $no-gap-breakpoint) { - border-top: 1px solid lighten($ui-base-color, 16%); - } - - &.compact { - .detailed-status__meta { - margin-top: 15px; - } - - .status__content { - font-size: 15px; - line-height: 20px; - - .emojione { - width: 20px; - height: 20px; - margin: -3px 0 0; - } - - .status__content__spoiler-link { - line-height: 20px; - margin: 0; - } - } - - .media-gallery, - .status-card, - .video-player { - margin-top: 15px; - } - } - } -} - -.notice-widget { - margin-bottom: 10px; - color: $darker-text-color; - - p { - margin-bottom: 10px; - - &:last-child { - margin-bottom: 0; - } - } - - a { - font-size: 14px; - line-height: 20px; - } -} - -.notice-widget, .placeholder-widget { a { text-decoration: none; @@ -602,37 +409,3 @@ $fluid-breakpoint: $maximum-width + 20px; } } } - -.table-of-contents { - background: darken($ui-base-color, 4%); - min-height: 100%; - font-size: 14px; - border-radius: 4px; - - li a { - display: block; - font-weight: 500; - padding: 15px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - text-decoration: none; - color: $primary-text-color; - border-bottom: 1px solid lighten($ui-base-color, 4%); - - &:hover, - &:focus, - &:active { - text-decoration: underline; - } - } - - li:last-child a { - border-bottom: 0; - } - - li ul { - padding-left: 20px; - border-bottom: 1px solid lighten($ui-base-color, 4%); - } -} diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb index b08687787..ad1dc2a38 100644 --- a/app/models/domain_block.rb +++ b/app/models/domain_block.rb @@ -28,8 +28,8 @@ class DomainBlock < ApplicationRecord delegate :count, to: :accounts, prefix: true scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) } - scope :with_user_facing_limitations, -> { where(severity: [:silence, :suspend]).or(where(reject_media: true)) } - scope :by_severity, -> { order(Arel.sql('(CASE severity WHEN 0 THEN 1 WHEN 1 THEN 2 WHEN 2 THEN 0 END), reject_media, domain')) } + scope :with_user_facing_limitations, -> { where(severity: [:silence, :suspend]) } + scope :by_severity, -> { order(Arel.sql('(CASE severity WHEN 0 THEN 1 WHEN 1 THEN 2 WHEN 2 THEN 0 END), domain')) } def to_log_human_identifier domain diff --git a/app/models/extended_description.rb b/app/models/extended_description.rb new file mode 100644 index 000000000..6e5c0d1b6 --- /dev/null +++ b/app/models/extended_description.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class ExtendedDescription < ActiveModelSerializers::Model + attributes :updated_at, :text + + def self.current + custom = Setting.find_by(var: 'site_extended_description') + + if custom&.value.present? + new(text: custom.value, updated_at: custom.updated_at) + else + new + end + end +end diff --git a/app/serializers/rest/domain_block_serializer.rb b/app/serializers/rest/domain_block_serializer.rb new file mode 100644 index 000000000..678463e13 --- /dev/null +++ b/app/serializers/rest/domain_block_serializer.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class REST::DomainBlockSerializer < ActiveModel::Serializer + attributes :domain, :digest, :severity, :comment + + def domain + object.public_domain + end + + def digest + object.domain_digest + end + + def comment + object.public_comment if instance_options[:with_comment] + end +end diff --git a/app/serializers/rest/extended_description_serializer.rb b/app/serializers/rest/extended_description_serializer.rb new file mode 100644 index 000000000..0c3649033 --- /dev/null +++ b/app/serializers/rest/extended_description_serializer.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class REST::ExtendedDescriptionSerializer < ActiveModel::Serializer + attributes :updated_at, :content + + def updated_at + object.updated_at&.iso8601 + end + + def content + object.text + end +end diff --git a/app/views/about/_domain_blocks.html.haml b/app/views/about/_domain_blocks.html.haml deleted file mode 100644 index 35a30f16e..000000000 --- a/app/views/about/_domain_blocks.html.haml +++ /dev/null @@ -1,12 +0,0 @@ -%table - %thead - %tr - %th= t('about.unavailable_content_description.domain') - %th= t('about.unavailable_content_description.reason') - %tbody - - domain_blocks.each do |domain_block| - %tr - %td.nowrap - %span{ title: "SHA-256: #{domain_block.domain_digest}" }= domain_block.public_domain - %td - = domain_block.public_comment if display_blocks_rationale? diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml deleted file mode 100644 index a5a10b620..000000000 --- a/app/views/about/more.html.haml +++ /dev/null @@ -1,96 +0,0 @@ -- content_for :page_title do - = site_hostname - -- content_for :header_tags do - = javascript_pack_tag 'public', crossorigin: 'anonymous' - = render partial: 'shared/og' - -.grid-4 - .column-0 - .public-account-header.public-account-header--no-bar - .public-account-header__image - = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.png'), alt: @instance_presenter.title, class: 'parallax' - - .column-1 - .landing-page__call-to-action{ dir: 'ltr' } - .row - .row__information-board - .information-board__section - %span= t 'about.user_count_before' - %strong= friendly_number_to_human @instance_presenter.user_count - %span= t 'about.user_count_after', count: @instance_presenter.user_count - .information-board__section - %span= t 'about.status_count_before' - %strong= friendly_number_to_human @instance_presenter.status_count - %span= t 'about.status_count_after', count: @instance_presenter.status_count - .row__mascot - .landing-page__mascot - = image_tag @instance_presenter.mascot&.file&.url || asset_pack_path('media/images/elephant_ui_plane.svg'), alt: '' - - .column-2 - .contact-widget - %h4= t 'about.administered_by' - - = account_link_to(@instance_presenter.contact.account) - - - if @instance_presenter.contact.email.present? - %h4 - = succeed ':' do - = t 'about.contact' - - = mail_to @instance_presenter.contact.email, nil, title: @instance_presenter.contact.email - - .column-3 - = render 'application/flashes' - - - if @contents.blank? && @rules.empty? && (!display_blocks? || @blocks&.empty?) - = nothing_here - - else - .box-widget - .rich-formatting - - unless @rules.empty? - %h2#rules= t('about.rules') - - %p= t('about.rules_html') - - %ol.rules-list - - @rules.each do |rule| - %li - .rules-list__text= rule.text - - = @contents.html_safe - - - if display_blocks? && !@blocks.empty? - %h2#unavailable-content= t('about.unavailable_content') - - %p= t('about.unavailable_content_html') - - - if (blocks = @blocks.select(&:reject_media?)) && !blocks.empty? - %h3= t('about.unavailable_content_description.rejecting_media_title') - %p= t('about.unavailable_content_description.rejecting_media') - = render partial: 'domain_blocks', locals: { domain_blocks: blocks } - - if (blocks = @blocks.select(&:silence?)) && !blocks.empty? - %h3= t('about.unavailable_content_description.silenced_title') - %p= t('about.unavailable_content_description.silenced') - = render partial: 'domain_blocks', locals: { domain_blocks: blocks } - - if (blocks = @blocks.select(&:suspend?)) && !blocks.empty? - %h3= t('about.unavailable_content_description.suspended_title') - %p= t('about.unavailable_content_description.suspended') - = render partial: 'domain_blocks', locals: { domain_blocks: blocks } - - .column-4 - %ul.table-of-contents - - unless @rules.empty? - %li= link_to t('about.rules'), '#rules' - - - @table_of_contents.each do |item| - %li - = link_to item.title, "##{item.anchor}" - - - unless item.children.empty? - %ul - - item.children.each do |sub_item| - %li= link_to sub_item.title, "##{sub_item.anchor}" - - - if display_blocks? && !@blocks.empty? - %li= link_to t('about.unavailable_content'), '#unavailable-content' diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml new file mode 100644 index 000000000..aff28b9a9 --- /dev/null +++ b/app/views/about/show.html.haml @@ -0,0 +1,4 @@ +- content_for :page_title do + = t('about.title') + += render partial: 'shared/web_app' diff --git a/config/locales/en.yml b/config/locales/en.yml index 8a70bd8ca..11716234e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2,41 +2,15 @@ en: about: about_mastodon_html: 'The social network of the future: No ads, no corporate surveillance, ethical design, and decentralization! Own your data with Mastodon!' - about_this: About - administered_by: 'Administered by:' api: API apps: Mobile apps - contact: Contact contact_missing: Not set contact_unavailable: N/A documentation: Documentation hosted_on: Mastodon hosted on %{domain} - instance_actor_flash: | - This account is a virtual actor used to represent the server itself and not any individual user. - It is used for federation purposes and should not be blocked unless you want to block the whole instance, in which case you should use a domain block. privacy_policy: Privacy Policy - rules: Server rules - rules_html: 'Below is a summary of rules you need to follow if you want to have an account on this server of Mastodon:' source_code: Source code - status_count_after: - one: post - other: posts - status_count_before: Who published - unavailable_content: Moderated servers - unavailable_content_description: - domain: Server - reason: Reason - rejecting_media: 'Media files from these servers will not be processed or stored, and no thumbnails will be displayed, requiring manual click-through to the original file:' - rejecting_media_title: Filtered media - silenced: 'Posts from these servers will be hidden in public timelines and conversations, and no notifications will be generated from their users interactions, unless you are following them:' - silenced_title: Limited servers - suspended: 'No data from these servers will be processed, stored or exchanged, making any interaction or communication with users from these servers impossible:' - suspended_title: Suspended servers - unavailable_content_html: 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. - user_count_after: - one: user - other: users - user_count_before: Home to + title: About what_is_mastodon: What is Mastodon? accounts: choices_html: "%{name}'s choices:" diff --git a/config/routes.rb b/config/routes.rb index e6098cd17..29ec0f8a5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -487,7 +487,9 @@ Rails.application.routes.draw do resource :instance, only: [:show] do resources :peers, only: [:index], controller: 'instances/peers' resources :rules, only: [:index], controller: 'instances/rules' + resources :domain_blocks, only: [:index], controller: 'instances/domain_blocks' resource :privacy_policy, only: [:show], controller: 'instances/privacy_policies' + resource :extended_description, only: [:show], controller: 'instances/extended_descriptions' resource :activity, only: [:show], controller: 'instances/activity' end @@ -642,8 +644,8 @@ Rails.application.routes.draw do get '/web/(*any)', to: 'home#index', as: :web - get '/about', to: redirect('/') - get '/about/more', to: 'about#more' + get '/about', to: 'about#show' + get '/about/more', to: redirect('/about') get '/privacy-policy', to: 'privacy#show', as: :privacy_policy get '/terms', to: redirect('/privacy-policy') diff --git a/spec/controllers/about_controller_spec.rb b/spec/controllers/about_controller_spec.rb index 20069e413..97143ec43 100644 --- a/spec/controllers/about_controller_spec.rb +++ b/spec/controllers/about_controller_spec.rb @@ -3,13 +3,9 @@ require 'rails_helper' RSpec.describe AboutController, type: :controller do render_views - describe 'GET #more' do + describe 'GET #show' do before do - get :more - end - - it 'assigns @instance_presenter' do - expect(assigns(:instance_presenter)).to be_kind_of InstancePresenter + get :show end it 'returns http success' do -- cgit From b0e3f0312c3271a2705f912602fcba70f4ed8b69 Mon Sep 17 00:00:00 2001 From: Takeshi Umeda Date: Thu, 20 Oct 2022 16:15:52 +0900 Subject: Add synchronization of remote featured tags (#19380) * Add LIMIT of featured tag to instance API response * Add featured_tags_collection_url to Account * Add synchronization of remote featured tags * Deliver update activity when updating featured tag * Remove featured_tags_collection_url * Revert "Add featured_tags_collection_url to Account" This reverts commit cff349fc27b104ded2df6bb5665132dc24dab09c. * Add hashtag sync from featured collections * Fix tag name normalize * Add target option to fetch featured collection * Refactor fetch_featured_tags_collection_service * Add LIMIT of featured tag to v1/instance API response --- app/controllers/api/v1/featured_tags_controller.rb | 2 + .../settings/featured_tags_controller.rb | 2 + app/models/featured_tag.rb | 4 +- app/serializers/rest/instance_serializer.rb | 4 ++ app/serializers/rest/v1/instance_serializer.rb | 4 ++ .../fetch_featured_collection_service.rb | 31 ++++++++- .../fetch_featured_tags_collection_service.rb | 78 ++++++++++++++++++++++ .../activitypub/process_account_service.rb | 7 +- .../synchronize_featured_collection_worker.rb | 6 +- .../synchronize_featured_tags_collection_worker.rb | 13 ++++ .../activitypub/update_distribution_worker.rb | 2 + 11 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 app/services/activitypub/fetch_featured_tags_collection_service.rb create mode 100644 app/workers/activitypub/synchronize_featured_tags_collection_worker.rb (limited to 'app/controllers/api/v1') diff --git a/app/controllers/api/v1/featured_tags_controller.rb b/app/controllers/api/v1/featured_tags_controller.rb index c1ead4f54..a67db7040 100644 --- a/app/controllers/api/v1/featured_tags_controller.rb +++ b/app/controllers/api/v1/featured_tags_controller.rb @@ -14,11 +14,13 @@ class Api::V1::FeaturedTagsController < Api::BaseController def create @featured_tag = current_account.featured_tags.create!(featured_tag_params) + ActivityPub::UpdateDistributionWorker.perform_in(3.minutes, current_account.id) render json: @featured_tag, serializer: REST::FeaturedTagSerializer end def destroy @featured_tag.destroy! + ActivityPub::UpdateDistributionWorker.perform_in(3.minutes, current_account.id) render_empty end diff --git a/app/controllers/settings/featured_tags_controller.rb b/app/controllers/settings/featured_tags_controller.rb index aadff7c83..ae714e912 100644 --- a/app/controllers/settings/featured_tags_controller.rb +++ b/app/controllers/settings/featured_tags_controller.rb @@ -13,6 +13,7 @@ class Settings::FeaturedTagsController < Settings::BaseController @featured_tag = current_account.featured_tags.new(featured_tag_params) if @featured_tag.save + ActivityPub::UpdateDistributionWorker.perform_in(3.minutes, current_account.id) redirect_to settings_featured_tags_path else set_featured_tags @@ -24,6 +25,7 @@ class Settings::FeaturedTagsController < Settings::BaseController def destroy @featured_tag.destroy! + ActivityPub::UpdateDistributionWorker.perform_in(3.minutes, current_account.id) redirect_to settings_featured_tags_path end diff --git a/app/models/featured_tag.rb b/app/models/featured_tag.rb index 201ce75f5..b16c79ac7 100644 --- a/app/models/featured_tag.rb +++ b/app/models/featured_tag.rb @@ -26,6 +26,8 @@ class FeaturedTag < ApplicationRecord attr_writer :name + LIMIT = 10 + def name tag_id.present? ? tag.name : @name end @@ -50,7 +52,7 @@ class FeaturedTag < ApplicationRecord end def validate_featured_tags_limit - errors.add(:base, I18n.t('featured_tags.errors.limit')) if account.featured_tags.count >= 10 + errors.add(:base, I18n.t('featured_tags.errors.limit')) if account.featured_tags.count >= LIMIT end def validate_tag_name diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb index dfa8ce40a..7d00b20ba 100644 --- a/app/serializers/rest/instance_serializer.rb +++ b/app/serializers/rest/instance_serializer.rb @@ -47,6 +47,10 @@ class REST::InstanceSerializer < ActiveModel::Serializer streaming: Rails.configuration.x.streaming_api_base_url, }, + accounts: { + max_featured_tags: FeaturedTag::LIMIT, + }, + statuses: { max_characters: StatusLengthValidator::MAX_CHARS, max_media_attachments: 4, diff --git a/app/serializers/rest/v1/instance_serializer.rb b/app/serializers/rest/v1/instance_serializer.rb index 872175451..99d1b2bd6 100644 --- a/app/serializers/rest/v1/instance_serializer.rb +++ b/app/serializers/rest/v1/instance_serializer.rb @@ -58,6 +58,10 @@ class REST::V1::InstanceSerializer < ActiveModel::Serializer def configuration { + accounts: { + max_featured_tags: FeaturedTag::LIMIT, + }, + statuses: { max_characters: StatusLengthValidator::MAX_CHARS, max_media_attachments: 4, diff --git a/app/services/activitypub/fetch_featured_collection_service.rb b/app/services/activitypub/fetch_featured_collection_service.rb index 37d05e055..026fe24c5 100644 --- a/app/services/activitypub/fetch_featured_collection_service.rb +++ b/app/services/activitypub/fetch_featured_collection_service.rb @@ -3,10 +3,11 @@ class ActivityPub::FetchFeaturedCollectionService < BaseService include JsonLdHelper - def call(account) + def call(account, **options) return if account.featured_collection_url.blank? || account.suspended? || account.local? @account = account + @options = options @json = fetch_resource(@account.featured_collection_url, true, local_follower) return unless supported_context?(@json) @@ -36,7 +37,15 @@ class ActivityPub::FetchFeaturedCollectionService < BaseService end def process_items(items) + process_note_items(items) if @options[:note] + process_hashtag_items(items) if @options[:hashtag] + end + + def process_note_items(items) status_ids = items.filter_map do |item| + type = item['type'] + next unless type == 'Note' + uri = value_or_id(item) next if ActivityPub::TagManager.instance.local_uri?(uri) @@ -67,6 +76,26 @@ class ActivityPub::FetchFeaturedCollectionService < BaseService end end + def process_hashtag_items(items) + names = items.filter_map { |item| item['type'] == 'Hashtag' && item['name']&.delete_prefix('#') }.map { |name| HashtagNormalizer.new.normalize(name) } + to_remove = [] + to_add = names + + FeaturedTag.where(account: @account).map(&:name).each do |name| + if names.include?(name) + to_add.delete(name) + else + to_remove << name + end + end + + FeaturedTag.includes(:tag).where(account: @account, tags: { name: to_remove }).delete_all unless to_remove.empty? + + to_add.each do |name| + FeaturedTag.create!(account: @account, name: name) + end + end + def local_follower return @local_follower if defined?(@local_follower) diff --git a/app/services/activitypub/fetch_featured_tags_collection_service.rb b/app/services/activitypub/fetch_featured_tags_collection_service.rb new file mode 100644 index 000000000..555919938 --- /dev/null +++ b/app/services/activitypub/fetch_featured_tags_collection_service.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +class ActivityPub::FetchFeaturedTagsCollectionService < BaseService + include JsonLdHelper + + def call(account, url) + return if url.blank? || account.suspended? || account.local? + + @account = account + @json = fetch_resource(url, true, local_follower) + + return unless supported_context?(@json) + + process_items(collection_items(@json)) + end + + private + + def collection_items(collection) + all_items = [] + + collection = fetch_collection(collection['first']) if collection['first'].present? + + while collection.is_a?(Hash) + items = begin + case collection['type'] + when 'Collection', 'CollectionPage' + collection['items'] + when 'OrderedCollection', 'OrderedCollectionPage' + collection['orderedItems'] + end + end + + break if items.blank? + + all_items.concat(items) + + break if all_items.size >= FeaturedTag::LIMIT + + collection = collection['next'].present? ? fetch_collection(collection['next']) : nil + end + + all_items + end + + def fetch_collection(collection_or_uri) + return collection_or_uri if collection_or_uri.is_a?(Hash) + return if invalid_origin?(collection_or_uri) + + fetch_resource_without_id_validation(collection_or_uri, local_follower, true) + end + + def process_items(items) + names = items.filter_map { |item| item['type'] == 'Hashtag' && item['name']&.delete_prefix('#') }.map { |name| HashtagNormalizer.new.normalize(name) } + to_remove = [] + to_add = names + + FeaturedTag.where(account: @account).map(&:name).each do |name| + if names.include?(name) + to_add.delete(name) + else + to_remove << name + end + end + + FeaturedTag.includes(:tag).where(account: @account, tags: { name: to_remove }).delete_all unless to_remove.empty? + + to_add.each do |name| + FeaturedTag.create!(account: @account, name: name) + end + end + + def local_follower + return @local_follower if defined?(@local_follower) + + @local_follower = @account.followers.local.without_suspended.first + end +end diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index 456b3524b..3834d79cc 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -39,6 +39,7 @@ class ActivityPub::ProcessAccountService < BaseService unless @options[:only_key] || @account.suspended? check_featured_collection! if @account.featured_collection_url.present? + check_featured_tags_collection! if @json['featuredTags'].present? check_links! unless @account.fields.empty? end @@ -149,7 +150,11 @@ class ActivityPub::ProcessAccountService < BaseService end def check_featured_collection! - ActivityPub::SynchronizeFeaturedCollectionWorker.perform_async(@account.id) + ActivityPub::SynchronizeFeaturedCollectionWorker.perform_async(@account.id, { 'hashtag' => @json['featuredTags'].blank? }) + end + + def check_featured_tags_collection! + ActivityPub::SynchronizeFeaturedTagsCollectionWorker.perform_async(@account.id, @json['featuredTags']) end def check_links! diff --git a/app/workers/activitypub/synchronize_featured_collection_worker.rb b/app/workers/activitypub/synchronize_featured_collection_worker.rb index 7a0898e89..f67d693cb 100644 --- a/app/workers/activitypub/synchronize_featured_collection_worker.rb +++ b/app/workers/activitypub/synchronize_featured_collection_worker.rb @@ -5,8 +5,10 @@ class ActivityPub::SynchronizeFeaturedCollectionWorker sidekiq_options queue: 'pull', lock: :until_executed - def perform(account_id) - ActivityPub::FetchFeaturedCollectionService.new.call(Account.find(account_id)) + def perform(account_id, options = {}) + options = { note: true, hashtag: false }.deep_merge(options.deep_symbolize_keys) + + ActivityPub::FetchFeaturedCollectionService.new.call(Account.find(account_id), **options) rescue ActiveRecord::RecordNotFound true end diff --git a/app/workers/activitypub/synchronize_featured_tags_collection_worker.rb b/app/workers/activitypub/synchronize_featured_tags_collection_worker.rb new file mode 100644 index 000000000..14af4f725 --- /dev/null +++ b/app/workers/activitypub/synchronize_featured_tags_collection_worker.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ActivityPub::SynchronizeFeaturedTagsCollectionWorker + include Sidekiq::Worker + + sidekiq_options queue: 'pull', lock: :until_executed + + def perform(account_id, url) + ActivityPub::FetchFeaturedTagsCollectionService.new.call(Account.find(account_id), url) + rescue ActiveRecord::RecordNotFound + true + end +end diff --git a/app/workers/activitypub/update_distribution_worker.rb b/app/workers/activitypub/update_distribution_worker.rb index 81fde63b8..d0391bb6f 100644 --- a/app/workers/activitypub/update_distribution_worker.rb +++ b/app/workers/activitypub/update_distribution_worker.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ActivityPub::UpdateDistributionWorker < ActivityPub::RawDistributionWorker + sidekiq_options queue: 'push', lock: :until_executed + # Distribute an profile update to servers that might have a copy # of the account in question def perform(account_id, options = {}) -- cgit From 74ead7d10698c7f18ca22c07d2f04ff81f419097 Mon Sep 17 00:00:00 2001 From: Takeshi Umeda Date: Sun, 23 Oct 2022 01:30:55 +0900 Subject: Change featured tag updates to add/remove activity (#19409) * Change featured tag updates to add/remove activity * Fix to check for the existence of feature tag * Rename service and worker * Merge AddHashtagSerializer with AddSerializer * Undo removal of sidekiq_options --- app/controllers/api/v1/featured_tags_controller.rb | 8 +++----- .../settings/featured_tags_controller.rb | 13 ++++++------ app/models/featured_tag.rb | 4 ++++ app/serializers/activitypub/add_serializer.rb | 23 ++++++++++++++++++++-- app/serializers/activitypub/hashtag_serializer.rb | 2 ++ app/serializers/activitypub/remove_serializer.rb | 23 ++++++++++++++++++++-- app/services/create_featured_tag_service.rb | 21 ++++++++++++++++++++ app/services/remove_featured_tag_service.rb | 18 +++++++++++++++++ .../activitypub/account_raw_distribution_worker.rb | 9 +++++++++ app/workers/remove_featured_tag_worker.rb | 11 +++++++++++ 10 files changed, 117 insertions(+), 15 deletions(-) create mode 100644 app/services/create_featured_tag_service.rb create mode 100644 app/services/remove_featured_tag_service.rb create mode 100644 app/workers/activitypub/account_raw_distribution_worker.rb create mode 100644 app/workers/remove_featured_tag_worker.rb (limited to 'app/controllers/api/v1') diff --git a/app/controllers/api/v1/featured_tags_controller.rb b/app/controllers/api/v1/featured_tags_controller.rb index a67db7040..edb42a94e 100644 --- a/app/controllers/api/v1/featured_tags_controller.rb +++ b/app/controllers/api/v1/featured_tags_controller.rb @@ -13,14 +13,12 @@ class Api::V1::FeaturedTagsController < Api::BaseController end def create - @featured_tag = current_account.featured_tags.create!(featured_tag_params) - ActivityPub::UpdateDistributionWorker.perform_in(3.minutes, current_account.id) - render json: @featured_tag, serializer: REST::FeaturedTagSerializer + featured_tag = CreateFeaturedTagService.new.call(current_account, featured_tag_params[:name]) + render json: featured_tag, serializer: REST::FeaturedTagSerializer end def destroy - @featured_tag.destroy! - ActivityPub::UpdateDistributionWorker.perform_in(3.minutes, current_account.id) + RemoveFeaturedTagWorker.perform_async(current_account.id, @featured_tag.id) render_empty end diff --git a/app/controllers/settings/featured_tags_controller.rb b/app/controllers/settings/featured_tags_controller.rb index ae714e912..cc6ec0843 100644 --- a/app/controllers/settings/featured_tags_controller.rb +++ b/app/controllers/settings/featured_tags_controller.rb @@ -10,10 +10,8 @@ class Settings::FeaturedTagsController < Settings::BaseController end def create - @featured_tag = current_account.featured_tags.new(featured_tag_params) - - if @featured_tag.save - ActivityPub::UpdateDistributionWorker.perform_in(3.minutes, current_account.id) + if !featured_tag_exists? + CreateFeaturedTagService.new.call(current_account, featured_tag_params[:name]) redirect_to settings_featured_tags_path else set_featured_tags @@ -24,13 +22,16 @@ class Settings::FeaturedTagsController < Settings::BaseController end def destroy - @featured_tag.destroy! - ActivityPub::UpdateDistributionWorker.perform_in(3.minutes, current_account.id) + RemoveFeaturedTagWorker.perform_async(current_account.id, @featured_tag.id) redirect_to settings_featured_tags_path end private + def featured_tag_exists? + current_account.featured_tags.by_name(featured_tag_params[:name]).exists? + end + def set_featured_tag @featured_tag = current_account.featured_tags.find(params[:id]) end diff --git a/app/models/featured_tag.rb b/app/models/featured_tag.rb index ec234a6fd..3f5cddce6 100644 --- a/app/models/featured_tag.rb +++ b/app/models/featured_tag.rb @@ -30,6 +30,10 @@ class FeaturedTag < ApplicationRecord LIMIT = 10 + def sign? + true + end + def name tag_id.present? ? tag.name : @name end diff --git a/app/serializers/activitypub/add_serializer.rb b/app/serializers/activitypub/add_serializer.rb index 6f5aab17f..436b05086 100644 --- a/app/serializers/activitypub/add_serializer.rb +++ b/app/serializers/activitypub/add_serializer.rb @@ -1,10 +1,29 @@ # frozen_string_literal: true class ActivityPub::AddSerializer < ActivityPub::Serializer + class UriSerializer < ActiveModel::Serializer + include RoutingHelper + + def serializable_hash(*_args) + ActivityPub::TagManager.instance.uri_for(object) + end + end + + def self.serializer_for(model, options) + case model.class.name + when 'Status' + UriSerializer + when 'FeaturedTag' + ActivityPub::HashtagSerializer + else + super + end + end + include RoutingHelper attributes :type, :actor, :target - attribute :proper_object, key: :object + has_one :proper_object, key: :object def type 'Add' @@ -15,7 +34,7 @@ class ActivityPub::AddSerializer < ActivityPub::Serializer end def proper_object - ActivityPub::TagManager.instance.uri_for(object) + object end def target diff --git a/app/serializers/activitypub/hashtag_serializer.rb b/app/serializers/activitypub/hashtag_serializer.rb index 90929c57f..2b24eb8cc 100644 --- a/app/serializers/activitypub/hashtag_serializer.rb +++ b/app/serializers/activitypub/hashtag_serializer.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ActivityPub::HashtagSerializer < ActivityPub::Serializer + context_extensions :hashtag + include RoutingHelper attributes :type, :href, :name diff --git a/app/serializers/activitypub/remove_serializer.rb b/app/serializers/activitypub/remove_serializer.rb index 7fefda59d..fb224f8a9 100644 --- a/app/serializers/activitypub/remove_serializer.rb +++ b/app/serializers/activitypub/remove_serializer.rb @@ -1,10 +1,29 @@ # frozen_string_literal: true class ActivityPub::RemoveSerializer < ActivityPub::Serializer + class UriSerializer < ActiveModel::Serializer + include RoutingHelper + + def serializable_hash(*_args) + ActivityPub::TagManager.instance.uri_for(object) + end + end + + def self.serializer_for(model, options) + case model.class.name + when 'Status' + UriSerializer + when 'FeaturedTag' + ActivityPub::HashtagSerializer + else + super + end + end + include RoutingHelper attributes :type, :actor, :target - attribute :proper_object, key: :object + has_one :proper_object, key: :object def type 'Remove' @@ -15,7 +34,7 @@ class ActivityPub::RemoveSerializer < ActivityPub::Serializer end def proper_object - ActivityPub::TagManager.instance.uri_for(object) + object end def target diff --git a/app/services/create_featured_tag_service.rb b/app/services/create_featured_tag_service.rb new file mode 100644 index 000000000..c99d16113 --- /dev/null +++ b/app/services/create_featured_tag_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class CreateFeaturedTagService < BaseService + include Payloadable + + def call(account, name) + @account = account + + FeaturedTag.create!(account: account, name: name).tap do |featured_tag| + ActivityPub::AccountRawDistributionWorker.perform_async(build_json(featured_tag), account.id) if @account.local? + end + rescue ActiveRecord::RecordNotUnique + FeaturedTag.by_name(name).find_by!(account: account) + end + + private + + def build_json(featured_tag) + Oj.dump(serialize_payload(featured_tag, ActivityPub::AddSerializer, signer: @account)) + end +end diff --git a/app/services/remove_featured_tag_service.rb b/app/services/remove_featured_tag_service.rb new file mode 100644 index 000000000..2aa70e8fc --- /dev/null +++ b/app/services/remove_featured_tag_service.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class RemoveFeaturedTagService < BaseService + include Payloadable + + def call(account, featured_tag) + @account = account + + featured_tag.destroy! + ActivityPub::AccountRawDistributionWorker.perform_async(build_json(featured_tag), account.id) if @account.local? + end + + private + + def build_json(featured_tag) + Oj.dump(serialize_payload(featured_tag, ActivityPub::RemoveSerializer, signer: @account)) + end +end diff --git a/app/workers/activitypub/account_raw_distribution_worker.rb b/app/workers/activitypub/account_raw_distribution_worker.rb new file mode 100644 index 000000000..a84c7d214 --- /dev/null +++ b/app/workers/activitypub/account_raw_distribution_worker.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class ActivityPub::AccountRawDistributionWorker < ActivityPub::RawDistributionWorker + protected + + def inboxes + @inboxes ||= AccountReachFinder.new(@account).inboxes + end +end diff --git a/app/workers/remove_featured_tag_worker.rb b/app/workers/remove_featured_tag_worker.rb new file mode 100644 index 000000000..065ec79d8 --- /dev/null +++ b/app/workers/remove_featured_tag_worker.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class RemoveFeaturedTagWorker + include Sidekiq::Worker + + def perform(account_id, featured_tag_id) + RemoveFeaturedTagService.new.call(Account.find(account_id), FeaturedTag.find(featured_tag_id)) + rescue ActiveRecord::RecordNotFound + true + end +end -- cgit