diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index e0383b83bc..fbc0d9da0c 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -1,31 +1,51 @@ name: 'Chromatic' +permissions: + contents: read on: push: branches-ignore: - renovate/* - stable-* - paths: - - 'package.json' - - 'yarn.lock' - - '**/*.js' - - '**/*.jsx' - - '**/*.ts' - - '**/*.tsx' - - '**/*.css' - - '**/*.scss' - - '.github/workflows/chromatic.yml' jobs: - chromatic: - name: Run Chromatic + pathcheck: + name: Check for relevant changes runs-on: ubuntu-latest - if: github.repository == 'mastodon/mastodon' + outputs: + changed: ${{ steps.filter.outputs.src }} steps: - name: Checkout code uses: actions/checkout@v5 with: fetch-depth: 0 + + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + src: + - 'package.json' + - 'yarn.lock' + - '**/*.js' + - '**/*.jsx' + - '**/*.ts' + - '**/*.tsx' + - '**/*.css' + - '**/*.scss' + - '.github/workflows/chromatic.yml' + + chromatic: + name: Run Chromatic + runs-on: ubuntu-latest + needs: pathcheck + if: github.repository == 'mastodon/mastodon' && needs.pathcheck.outputs.changed == 'true' + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Set up Javascript environment uses: ./.github/actions/setup-javascript @@ -35,7 +55,8 @@ jobs: - name: Run Chromatic uses: chromaui/action@v13 with: - # ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} zip: true storybookBuildDir: 'storybook-static' + exitZeroOnChanges: false # Fail workflow if changes are found + autoAcceptChanges: 'main' # Auto-accept changes on main branch only diff --git a/AUTHORS.md b/AUTHORS.md index 78cc37a17b..5d243ed43b 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -538,7 +538,7 @@ and provided thanks to the work of the following contributors: * [Drew Schuster](mailto:dtschust@gmail.com) * [Dryusdan](mailto:dryusdan@dryusdan.fr) * [Eai](mailto:eai@mizle.net) -* [Eashwar Ranganathan](mailto:eranganathan@lyft.com) +* [Eashwar Ranganathan](mailto:eashwar@eashwar.com) * [Ed Knutson](mailto:knutsoned@gmail.com) * [Elizabeth Martín Campos](mailto:me@elizabeth.sh) * [Elizabeth Myers](mailto:elizabeth@interlinked.me) diff --git a/Gemfile b/Gemfile index 599686d5ba..9394aa096c 100644 --- a/Gemfile +++ b/Gemfile @@ -40,7 +40,7 @@ gem 'net-ldap', '~> 0.18' gem 'omniauth', '~> 2.0' gem 'omniauth-cas', '~> 3.0.0.beta.1' gem 'omniauth_openid_connect', '~> 0.8.0' -gem 'omniauth-rails_csrf_protection', '~> 1.0' +gem 'omniauth-rails_csrf_protection', '~> 2.0' gem 'omniauth-saml', '~> 2.0' gem 'color_diff', '~> 0.1' @@ -71,7 +71,7 @@ gem 'oj', '~> 3.14' gem 'ox', '~> 2.14' gem 'parslet' gem 'premailer-rails' -gem 'public_suffix', '~> 6.0' +gem 'public_suffix', '~> 7.0' gem 'pundit', '~> 2.3' gem 'rack-attack', '~> 6.6' gem 'rack-cors', require: 'rack/cors' diff --git a/Gemfile.lock b/Gemfile.lock index 19dfbfa5dd..daa470e2d4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -86,8 +86,8 @@ GEM securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) aes_key_wrap (1.1.0) android_key_attestation (0.3.0) annotaterb (4.20.0) @@ -97,7 +97,7 @@ GEM attr_required (1.0.2) aws-eventstream (1.4.0) aws-partitions (1.1186.0) - aws-sdk-core (3.239.1) + aws-sdk-core (3.239.2) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -167,7 +167,7 @@ GEM cocoon (1.2.15) color_diff (0.1) concurrent-ruby (1.3.5) - connection_pool (2.5.4) + connection_pool (2.5.5) cose (1.3.1) cbor (~> 0.5.9) openssl-signature_algorithm (~> 1.0) @@ -324,7 +324,7 @@ GEM rainbow (>= 2.0.0) i18n (1.14.7) concurrent-ruby (~> 1.0) - i18n-tasks (1.1.1) + i18n-tasks (1.1.2) activesupport (>= 4.0.2) ast (>= 2.1.0) erubi @@ -481,7 +481,7 @@ GEM addressable (~> 2.8) nokogiri (~> 1.12) omniauth (~> 2.1) - omniauth-rails_csrf_protection (1.0.2) + omniauth-rails_csrf_protection (2.0.0) actionpack (>= 4.2) omniauth (~> 2.0) omniauth-saml (2.2.4) @@ -542,15 +542,15 @@ GEM opentelemetry-registry (~> 0.1) opentelemetry-instrumentation-concurrent_ruby (0.24.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-excon (0.26.0) + opentelemetry-instrumentation-excon (0.26.1) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-faraday (0.30.0) + opentelemetry-instrumentation-faraday (0.30.1) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-http (0.27.0) + opentelemetry-instrumentation-http (0.27.1) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-http_client (0.26.0) + opentelemetry-instrumentation-http_client (0.26.1) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-net_http (0.26.0) + opentelemetry-instrumentation-net_http (0.26.1) opentelemetry-instrumentation-base (~> 0.25) opentelemetry-instrumentation-pg (0.33.0) opentelemetry-helpers-sql @@ -618,7 +618,7 @@ GEM psych (5.2.6) date stringio - public_suffix (6.0.2) + public_suffix (7.0.0) puma (7.1.0) nio4r (~> 2.0) pundit (2.5.2) @@ -638,7 +638,7 @@ GEM faraday-follow_redirects json-jwt (>= 1.11.0) rack (>= 2.1.0) - rack-protection (4.1.1) + rack-protection (4.2.1) base64 (>= 0.1.0) logger (>= 1.6.0) rack (>= 3.0.0, < 4) @@ -672,7 +672,7 @@ GEM rails-html-sanitizer (1.6.2) loofah (~> 2.21) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - rails-i18n (8.0.2) + rails-i18n (8.1.0) i18n (>= 0.7, < 2) railties (>= 8.0.0, < 9) railties (8.0.3) @@ -717,10 +717,10 @@ GEM rotp (6.3.0) rouge (4.6.1) rpam2 (4.0.2) - rqrcode (3.1.0) + rqrcode (3.1.1) chunky_png (~> 1.0) rqrcode_core (~> 2.0) - rqrcode_core (2.0.0) + rqrcode_core (2.0.1) rspec (3.13.1) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) @@ -1009,7 +1009,7 @@ DEPENDENCIES oj (~> 3.14) omniauth (~> 2.0) omniauth-cas (~> 3.0.0.beta.1) - omniauth-rails_csrf_protection (~> 1.0) + omniauth-rails_csrf_protection (~> 2.0) omniauth-saml (~> 2.0) omniauth_openid_connect (~> 0.8.0) opentelemetry-api (~> 1.7.0) @@ -1036,7 +1036,7 @@ DEPENDENCIES premailer-rails prometheus_exporter (~> 2.2) propshaft - public_suffix (~> 6.0) + public_suffix (~> 7.0) puma (~> 7.0) pundit (~> 2.3) rack-attack (~> 6.6) diff --git a/app/controllers/api/v1_alpha/collections_controller.rb b/app/controllers/api/v1_alpha/collections_controller.rb new file mode 100644 index 0000000000..5583bb395d --- /dev/null +++ b/app/controllers/api/v1_alpha/collections_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class Api::V1Alpha::CollectionsController < Api::BaseController + rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e| + render json: { error: ValidationErrorFormatter.new(e).as_json }, status: 422 + end + + before_action :check_feature_enabled + + before_action -> { doorkeeper_authorize! :write, :'write:collections' }, only: [:create] + + before_action :require_user! + + def create + @collection = CreateCollectionService.new.call(collection_params, current_user.account) + + render json: @collection, serializer: REST::CollectionSerializer + end + + private + + def collection_params + params.permit(:name, :description, :sensitive, :discoverable, :tag, account_ids: []) + end + + def check_feature_enabled + raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled? + end +end diff --git a/app/javascript/flavours/glitch/actions/bundles.js b/app/javascript/flavours/glitch/actions/bundles.js deleted file mode 100644 index ecc9c8f7d3..0000000000 --- a/app/javascript/flavours/glitch/actions/bundles.js +++ /dev/null @@ -1,25 +0,0 @@ -export const BUNDLE_FETCH_REQUEST = 'BUNDLE_FETCH_REQUEST'; -export const BUNDLE_FETCH_SUCCESS = 'BUNDLE_FETCH_SUCCESS'; -export const BUNDLE_FETCH_FAIL = 'BUNDLE_FETCH_FAIL'; - -export function fetchBundleRequest(skipLoading) { - return { - type: BUNDLE_FETCH_REQUEST, - skipLoading, - }; -} - -export function fetchBundleSuccess(skipLoading) { - return { - type: BUNDLE_FETCH_SUCCESS, - skipLoading, - }; -} - -export function fetchBundleFail(error, skipLoading) { - return { - type: BUNDLE_FETCH_FAIL, - error, - skipLoading, - }; -} diff --git a/app/javascript/flavours/glitch/api_types/statuses.ts b/app/javascript/flavours/glitch/api_types/statuses.ts index 4ecb34bfe1..36468ce3bf 100644 --- a/app/javascript/flavours/glitch/api_types/statuses.ts +++ b/app/javascript/flavours/glitch/api_types/statuses.ts @@ -51,7 +51,7 @@ export interface ApiPreviewCardJSON { html: string; width: number; height: number; - image: string; + image: string | null; image_description: string; embed_url: string; blurhash: string; diff --git a/app/javascript/flavours/glitch/common.ts b/app/javascript/flavours/glitch/common.ts index e621a24e39..33d2b5ad17 100644 --- a/app/javascript/flavours/glitch/common.ts +++ b/app/javascript/flavours/glitch/common.ts @@ -1,9 +1,5 @@ -import Rails from '@rails/ujs'; +import { setupLinkListeners } from './utils/links'; export function start() { - try { - Rails.start(); - } catch { - // If called twice - } + setupLinkListeners(); } diff --git a/app/javascript/flavours/glitch/components/autosuggest_input.jsx b/app/javascript/flavours/glitch/components/autosuggest_input.jsx index f059356f09..9e342a353a 100644 --- a/app/javascript/flavours/glitch/components/autosuggest_input.jsx +++ b/app/javascript/flavours/glitch/components/autosuggest_input.jsx @@ -159,8 +159,8 @@ export default class AutosuggestInput extends ImmutablePureComponent { this.input.focus(); }; - UNSAFE_componentWillReceiveProps (nextProps) { - if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) { + componentDidUpdate (prevProps) { + if (prevProps.suggestions !== this.props.suggestions && this.props.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) { this.setState({ suggestionsHidden: false }); } } diff --git a/app/javascript/flavours/glitch/components/media_gallery.jsx b/app/javascript/flavours/glitch/components/media_gallery.jsx index 4abfe55e17..bf89a2d4f8 100644 --- a/app/javascript/flavours/glitch/components/media_gallery.jsx +++ b/app/javascript/flavours/glitch/components/media_gallery.jsx @@ -251,15 +251,13 @@ class MediaGallery extends PureComponent { window.removeEventListener('resize', this.handleResize); } - UNSAFE_componentWillReceiveProps (nextProps) { - if (!is(nextProps.media, this.props.media) && nextProps.visible === undefined) { - this.setState({ visible: displayMedia !== 'hide_all' && !nextProps.sensitive || displayMedia === 'show_all' }); - } else if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) { - this.setState({ visible: nextProps.visible }); + componentDidUpdate (prevProps) { + if (!is(prevProps.media, this.props.media) && this.props.visible === undefined) { + this.setState({ visible: displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all' }); + } else if (!is(prevProps.visible, this.props.visible) && this.props.visible !== undefined) { + this.setState({ visible: this.props.visible }); } - } - componentDidUpdate () { if (this.node) { this.handleResize(); } diff --git a/app/javascript/flavours/glitch/components/modal_root.jsx b/app/javascript/flavours/glitch/components/modal_root.jsx index 9bbe23ce0c..86016706a6 100644 --- a/app/javascript/flavours/glitch/components/modal_root.jsx +++ b/app/javascript/flavours/glitch/components/modal_root.jsx @@ -66,14 +66,6 @@ class ModalRoot extends PureComponent { } } - UNSAFE_componentWillReceiveProps (nextProps) { - if (!!nextProps.children && !this.props.children) { - this.activeElement = document.activeElement; - - this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true)); - } - } - componentDidUpdate (prevProps) { if (!this.props.children && !!prevProps.children) { this.getSiblings().forEach(sibling => sibling.removeAttribute('inert')); @@ -90,9 +82,15 @@ class ModalRoot extends PureComponent { this._handleModalClose(); } + if (this.props.children && !prevProps.children) { + this.activeElement = document.activeElement; + + this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true)); + this._handleModalOpen(); } + if (this.props.children) { this._ensureHistoryBuffer(); } diff --git a/app/javascript/flavours/glitch/components/status.jsx b/app/javascript/flavours/glitch/components/status.jsx index 98d1227c86..76e2182356 100644 --- a/app/javascript/flavours/glitch/components/status.jsx +++ b/app/javascript/flavours/glitch/components/status.jsx @@ -634,7 +634,7 @@ class Status extends ImmutablePureComponent { } else if (status.get('card') && settings.get('inline_preview_cards') && !this.props.muted && !status.get('quote')) { media.push( , diff --git a/app/javascript/flavours/glitch/features/blocks/index.jsx b/app/javascript/flavours/glitch/features/blocks/index.jsx index 3ce76c4330..6de7456243 100644 --- a/app/javascript/flavours/glitch/features/blocks/index.jsx +++ b/app/javascript/flavours/glitch/features/blocks/index.jsx @@ -38,7 +38,7 @@ class Blocks extends ImmutablePureComponent { multiColumn: PropTypes.bool, }; - UNSAFE_componentWillMount () { + componentDidMount () { this.props.dispatch(fetchBlocks()); } diff --git a/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx b/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx index d368fb6a20..699c0b4a2e 100644 --- a/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx @@ -61,8 +61,14 @@ class ModifierPickerMenu extends PureComponent { this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1); }; - UNSAFE_componentWillReceiveProps(nextProps) { - if (nextProps.active) { + componentDidMount() { + if (this.props.active) { + this.attachListeners(); + } + } + + componentDidUpdate() { + if (this.props.active) { this.attachListeners(); } else { this.removeListeners(); diff --git a/app/javascript/flavours/glitch/features/favourites/index.jsx b/app/javascript/flavours/glitch/features/favourites/index.jsx index 9e0b79ab5f..2c6289af3a 100644 --- a/app/javascript/flavours/glitch/features/favourites/index.jsx +++ b/app/javascript/flavours/glitch/features/favourites/index.jsx @@ -43,7 +43,7 @@ class Favourites extends ImmutablePureComponent { intl: PropTypes.object.isRequired, }; - UNSAFE_componentWillMount () { + componentDidMount () { if (!this.props.accountIds) { this.props.dispatch(fetchFavourites(this.props.params.statusId)); } diff --git a/app/javascript/flavours/glitch/features/follow_requests/index.jsx b/app/javascript/flavours/glitch/features/follow_requests/index.jsx index 7d651f2ca6..91648412b5 100644 --- a/app/javascript/flavours/glitch/features/follow_requests/index.jsx +++ b/app/javascript/flavours/glitch/features/follow_requests/index.jsx @@ -45,7 +45,7 @@ class FollowRequests extends ImmutablePureComponent { multiColumn: PropTypes.bool, }; - UNSAFE_componentWillMount () { + componentDidMount () { this.props.dispatch(fetchFollowRequests()); } diff --git a/app/javascript/flavours/glitch/features/list_timeline/index.jsx b/app/javascript/flavours/glitch/features/list_timeline/index.jsx index 6283379b5b..5b8194f666 100644 --- a/app/javascript/flavours/glitch/features/list_timeline/index.jsx +++ b/app/javascript/flavours/glitch/features/list_timeline/index.jsx @@ -73,11 +73,10 @@ class ListTimeline extends PureComponent { this.disconnect = dispatch(connectListStream(id)); } - UNSAFE_componentWillReceiveProps (nextProps) { - const { dispatch } = this.props; - const { id } = nextProps.params; + componentDidUpdate (prevProps) { + const { dispatch, params: {id} } = this.props; - if (id !== this.props.params.id) { + if (id !== prevProps.params.id) { if (this.disconnect) { this.disconnect(); this.disconnect = null; diff --git a/app/javascript/flavours/glitch/features/mutes/index.jsx b/app/javascript/flavours/glitch/features/mutes/index.jsx index 26ebff17f4..476606abf0 100644 --- a/app/javascript/flavours/glitch/features/mutes/index.jsx +++ b/app/javascript/flavours/glitch/features/mutes/index.jsx @@ -40,7 +40,7 @@ class Mutes extends ImmutablePureComponent { multiColumn: PropTypes.bool, }; - UNSAFE_componentWillMount () { + componentDidMount () { this.props.dispatch(fetchMutes()); } diff --git a/app/javascript/flavours/glitch/features/pinned_statuses/index.jsx b/app/javascript/flavours/glitch/features/pinned_statuses/index.jsx index 7b4e36cdaf..f50435a2f6 100644 --- a/app/javascript/flavours/glitch/features/pinned_statuses/index.jsx +++ b/app/javascript/flavours/glitch/features/pinned_statuses/index.jsx @@ -34,7 +34,7 @@ class PinnedStatuses extends ImmutablePureComponent { multiColumn: PropTypes.bool, }; - UNSAFE_componentWillMount () { + componentDidMount () { this.props.dispatch(fetchPinnedStatuses()); } diff --git a/app/javascript/flavours/glitch/features/reblogs/index.jsx b/app/javascript/flavours/glitch/features/reblogs/index.jsx index 69bc423563..6e7faa3db6 100644 --- a/app/javascript/flavours/glitch/features/reblogs/index.jsx +++ b/app/javascript/flavours/glitch/features/reblogs/index.jsx @@ -44,7 +44,7 @@ class Reblogs extends ImmutablePureComponent { intl: PropTypes.object.isRequired, }; - UNSAFE_componentWillMount () { + componentDidMount () { if (!this.props.accountIds) { this.props.dispatch(fetchReblogs(this.props.params.statusId)); } diff --git a/app/javascript/flavours/glitch/features/search/index.tsx b/app/javascript/flavours/glitch/features/search/index.tsx index a02a85096c..b7ba59e93f 100644 --- a/app/javascript/flavours/glitch/features/search/index.tsx +++ b/app/javascript/flavours/glitch/features/search/index.tsx @@ -53,7 +53,7 @@ const renderHashtags = (hashtags: HashtagType[]) => const renderStatuses = (statusIds: string[]) => hidePeek(statusIds).map((id) => ( - + )); type SearchType = 'all' | ApiSearchType; @@ -189,7 +189,7 @@ export const SearchResults: React.FC<{ multiColumn: boolean }> = ({ onClickMore={handleSelectStatuses} > {results.statuses.slice(0, INITIAL_DISPLAY).map((id) => ( - + ))} )} diff --git a/app/javascript/flavours/glitch/features/status/components/card.jsx b/app/javascript/flavours/glitch/features/status/components/card.jsx deleted file mode 100644 index cdef340ceb..0000000000 --- a/app/javascript/flavours/glitch/features/status/components/card.jsx +++ /dev/null @@ -1,243 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import { FormattedMessage } from 'react-intl'; - -import classNames from 'classnames'; - -import { is } from 'immutable'; -import ImmutablePropTypes from 'react-immutable-proptypes'; - -import DescriptionIcon from '@/material-icons/400-24px/description-fill.svg?react'; -import OpenInNewIcon from '@/material-icons/400-24px/open_in_new.svg?react'; -import PlayArrowIcon from '@/material-icons/400-24px/play_arrow-fill.svg?react'; -import { Blurhash } from 'flavours/glitch/components/blurhash'; -import { Icon } from 'flavours/glitch/components/icon'; -import { MoreFromAuthor } from 'flavours/glitch/components/more_from_author'; -import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp'; -import { useBlurhash } from 'flavours/glitch/initial_state'; -import { decode as decodeIDNA } from 'flavours/glitch/utils/idna'; - -const getHostname = url => { - const parser = document.createElement('a'); - parser.href = url; - return parser.hostname; -}; - -const domParser = new DOMParser(); - -const handleIframeUrl = (html, url, providerName) => { - const document = domParser.parseFromString(html, 'text/html').documentElement; - const iframe = document.querySelector('iframe'); - const startTime = new URL(url).searchParams.get('t') - - if (iframe) { - const iframeUrl = new URL(iframe.src) - - iframeUrl.searchParams.set('autoplay', 1) - iframeUrl.searchParams.set('auto_play', 1) - - if (startTime && providerName === "YouTube") iframeUrl.searchParams.set('start', startTime) - - iframe.src = iframeUrl.href - - // DOM parser creates html/body elements around original HTML fragment, - // so we need to get innerHTML out of the body and not the entire document - return document.querySelector('body').innerHTML; - } - - return html; -}; - -export default class Card extends PureComponent { - - static propTypes = { - card: ImmutablePropTypes.map, - onOpenMedia: PropTypes.func.isRequired, - sensitive: PropTypes.bool, - }; - - state = { - previewLoaded: false, - embedded: false, - revealed: !this.props.sensitive, - }; - - UNSAFE_componentWillReceiveProps (nextProps) { - if (!is(this.props.card, nextProps.card)) { - this.setState({ embedded: false, previewLoaded: false }); - } - - if (this.props.sensitive !== nextProps.sensitive) { - this.setState({ revealed: !nextProps.sensitive }); - } - } - - componentDidMount () { - window.addEventListener('resize', this.handleResize, { passive: true }); - } - - componentWillUnmount () { - window.removeEventListener('resize', this.handleResize); - } - - handleEmbedClick = () => { - this.setState({ embedded: true }); - }; - - handleExternalLinkClick = (e) => { - e.stopPropagation(); - }; - - setRef = c => { - this.node = c; - }; - - handleImageLoad = () => { - this.setState({ previewLoaded: true }); - }; - - handleReveal = e => { - e.preventDefault(); - e.stopPropagation(); - this.setState({ revealed: true }); - }; - - renderVideo () { - const { card } = this.props; - const content = { __html: handleIframeUrl(card.get('html'), card.get('url'), card.get('provider_name')) }; - - return ( -
- ); - } - - render () { - const { card } = this.props; - const { embedded, revealed } = this.state; - - if (card === null) { - return null; - } - - const provider = card.get('provider_name').length === 0 ? decodeIDNA(getHostname(card.get('url'))) : card.get('provider_name'); - const interactive = card.get('type') === 'video'; - const language = card.get('language') || ''; - const largeImage = (card.get('image')?.length > 0 && card.get('width') > card.get('height')) || interactive; - const showAuthor = !!card.getIn(['authors', 0, 'accountId']); - - const description = ( -
- - {provider} - {card.get('published_at') && <> · } - - - {card.get('title')} - - {!showAuthor && (card.get('author_name').length > 0 ? {card.get('author_name')} }} /> : {card.get('description')})} -
- ); - - const thumbnailStyle = { - visibility: revealed ? null : 'hidden', - }; - - if (largeImage && card.get('type') === 'video') { - thumbnailStyle.aspectRatio = `16 / 9`; - } else if (largeImage) { - thumbnailStyle.aspectRatio = '1.91 / 1'; - } else { - thumbnailStyle.aspectRatio = 1; - } - - let embed; - - let canvas = ( - - ); - - const thumbnailDescription = card.get('image_description'); - const thumbnail = {thumbnailDescription}; - - let spoilerButton = ( - - ); - - spoilerButton = ( -
- {spoilerButton} -
- ); - - if (interactive) { - if (embedded) { - embed = this.renderVideo(); - } else { - embed = ( -
- {canvas} - {thumbnail} - - {revealed ? ( -
-
- - -
-
- ) : spoilerButton} -
- ); - } - - return ( -
- {embed} - {description} -
- ); - } else if (card.get('image')) { - embed = ( -
- {canvas} - {thumbnail} -
- ); - } else { - embed = ( -
- -
- ); - } - - return ( - <> - - {embed} - {description} - - - {showAuthor && } - - ); - } - -} diff --git a/app/javascript/flavours/glitch/features/status/components/card.tsx b/app/javascript/flavours/glitch/features/status/components/card.tsx new file mode 100644 index 0000000000..ef1b05064f --- /dev/null +++ b/app/javascript/flavours/glitch/features/status/components/card.tsx @@ -0,0 +1,302 @@ +import { useCallback, useId, useState } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import DescriptionIcon from '@/material-icons/400-24px/description-fill.svg?react'; +import OpenInNewIcon from '@/material-icons/400-24px/open_in_new.svg?react'; +import PlayArrowIcon from '@/material-icons/400-24px/play_arrow-fill.svg?react'; +import { Blurhash } from 'flavours/glitch/components/blurhash'; +import { Icon } from 'flavours/glitch/components/icon'; +import { MoreFromAuthor } from 'flavours/glitch/components/more_from_author'; +import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp'; +import { useBlurhash } from 'flavours/glitch/initial_state'; +import type { Card as CardType } from 'flavours/glitch/models/status'; +import { decode as decodeIDNA } from 'flavours/glitch/utils/idna'; + +const getHostname = (url: string) => { + const parser = document.createElement('a'); + parser.href = url; + return parser.hostname; +}; + +const domParser = new DOMParser(); + +const handleIframeUrl = (html: string, url: string, providerName: string) => { + const document = domParser.parseFromString(html, 'text/html').documentElement; + const iframe = document.querySelector('iframe'); + const startTime = new URL(url).searchParams.get('t'); + + if (iframe) { + const iframeUrl = new URL(iframe.src); + + iframeUrl.searchParams.set('autoplay', '1'); + iframeUrl.searchParams.set('auto_play', '1'); + + if (startTime && providerName === 'YouTube') + iframeUrl.searchParams.set('start', startTime); + + iframe.src = iframeUrl.href; + + // DOM parser creates html/body elements around original HTML fragment, + // so we need to get innerHTML out of the body and not the entire document + return document.querySelector('body')?.innerHTML ?? ''; + } + + return html; +}; + +interface CardProps { + card: CardType | null; + sensitive?: boolean; +} + +const CardVideo: React.FC> = ({ card }) => ( +
+); + +const Card: React.FC = ({ card, sensitive }) => { + const [previewLoaded, setPreviewLoaded] = useState(false); + const [embedded, setEmbedded] = useState(false); + const [revealed, setRevealed] = useState(!sensitive); + + const handleEmbedClick = useCallback(() => { + setEmbedded(true); + }, []); + + const handleExternalLinkClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + }, []); + + const handleImageLoad = useCallback(() => { + setPreviewLoaded(true); + }, []); + + const handleReveal = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setRevealed(true); + }, []); + + const spoilerButtonId = useId(); + + if (card === null) { + return null; + } + + const provider = + card.get('provider_name').length === 0 + ? decodeIDNA(getHostname(card.get('url'))) + : card.get('provider_name'); + const interactive = card.get('type') === 'video'; + const language = card.get('language') || ''; + const hasImage = (card.get('image')?.length ?? 0) > 0; + const largeImage = + (hasImage && card.get('width') > card.get('height')) || interactive; + const showAuthor = !!card.getIn(['authors', 0, 'accountId']); + + const description = ( +
+ + {provider} + {card.get('published_at') && ( + <> + {' '} + · + + )} + + + + {card.get('title')} + + + {!showAuthor && + (card.get('author_name').length > 0 ? ( + + {card.get('author_name')} }} + /> + + ) : ( + + {card.get('description')} + + ))} +
+ ); + + const thumbnailStyle: React.CSSProperties = { + visibility: revealed ? undefined : 'hidden', + aspectRatio: '1', + }; + + if (largeImage && card.get('type') === 'video') { + thumbnailStyle.aspectRatio = `16 / 9`; + } else if (largeImage) { + thumbnailStyle.aspectRatio = '1.91 / 1'; + } + + let embed; + + const canvas = ( + + ); + + const thumbnailDescription = card.get('image_description'); + const thumbnail = ( + {thumbnailDescription} + ); + + const spoilerButton = ( +
+ +
+ ); + + if (interactive) { + if (embedded) { + embed = ; + } else { + embed = ( +
+ {canvas} + {thumbnail} + + {revealed ? ( +
+
+ + + + +
+
+ ) : ( + spoilerButton + )} +
+ ); + } + + return ( +
+ {embed} + + {description} + +
+ ); + } else if (card.get('image')) { + embed = ( +
+ {canvas} + {thumbnail} +
+ ); + } else { + embed = ( +
+ +
+ ); + } + + return ( + <> + + {embed} + {description} + + + {showAuthor && ( + + )} + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default Card; diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.tsx b/app/javascript/flavours/glitch/features/status/components/detailed_status.tsx index 7175cc9f40..a72d9ad714 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.tsx +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.tsx @@ -294,8 +294,8 @@ export const DetailedStatus: React.FC<{ } else if (status.get('card') && !status.get('quote')) { media = ( ); diff --git a/app/javascript/flavours/glitch/features/ui/components/bundle.jsx b/app/javascript/flavours/glitch/features/ui/components/bundle.jsx index 15c4220b34..57143f1571 100644 --- a/app/javascript/flavours/glitch/features/ui/components/bundle.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/bundle.jsx @@ -2,7 +2,6 @@ import PropTypes from 'prop-types'; import { PureComponent } from 'react'; const emptyComponent = () => null; -const noop = () => { }; class Bundle extends PureComponent { @@ -12,18 +11,12 @@ class Bundle extends PureComponent { error: PropTypes.func, children: PropTypes.func.isRequired, renderDelay: PropTypes.number, - onFetch: PropTypes.func, - onFetchSuccess: PropTypes.func, - onFetchFail: PropTypes.func, }; static defaultProps = { loading: emptyComponent, error: emptyComponent, renderDelay: 0, - onFetch: noop, - onFetchSuccess: noop, - onFetchFail: noop, }; static cache = new Map; @@ -33,13 +26,13 @@ class Bundle extends PureComponent { forceRender: false, }; - UNSAFE_componentWillMount() { + componentDidMount() { this.load(this.props); } - UNSAFE_componentWillReceiveProps(nextProps) { - if (nextProps.fetchComponent !== this.props.fetchComponent) { - this.load(nextProps); + componentDidUpdate(prevProps) { + if (prevProps.fetchComponent !== this.props.fetchComponent) { + this.load(this.props); } } @@ -50,7 +43,7 @@ class Bundle extends PureComponent { } load = (props) => { - const { fetchComponent, onFetch, onFetchSuccess, onFetchFail, renderDelay } = props || this.props; + const { fetchComponent, renderDelay } = props || this.props; const cachedMod = Bundle.cache.get(fetchComponent); if (fetchComponent === undefined) { @@ -58,11 +51,8 @@ class Bundle extends PureComponent { return Promise.resolve(); } - onFetch(); - if (cachedMod) { this.setState({ mod: cachedMod.default }); - onFetchSuccess(); return Promise.resolve(); } @@ -77,11 +67,9 @@ class Bundle extends PureComponent { .then((mod) => { Bundle.cache.set(fetchComponent, mod); this.setState({ mod: mod.default }); - onFetchSuccess(); }) .catch((error) => { this.setState({ mod: null }); - onFetchFail(error); }); }; diff --git a/app/javascript/flavours/glitch/features/ui/components/columns_area.jsx b/app/javascript/flavours/glitch/features/ui/components/columns_area.jsx index 54da547787..8be05802d6 100644 --- a/app/javascript/flavours/glitch/features/ui/components/columns_area.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.jsx @@ -5,7 +5,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { scrollRight } from '../../../scroll'; -import BundleContainer from '../containers/bundle_container'; import { Compose, Notifications, @@ -21,6 +20,7 @@ import { } from '../util/async-components'; import { useColumnsContext } from '../util/columns_context'; +import Bundle from './bundle'; import BundleColumnError from './bundle_column_error'; import { ColumnLoading } from './column_loading'; import { ComposePanel, RedirectToMobileComposeIfNeeded } from './compose_panel'; @@ -145,9 +145,9 @@ export default class ColumnsArea extends ImmutablePureComponent { const other = params && params.other ? params.other : {}; return ( - + {SpecificComponent => } - + ); })} diff --git a/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx b/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx index badd4d2cf1..cfbde0fb3e 100644 --- a/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx @@ -22,11 +22,10 @@ import { AnnualReportModal, } from 'flavours/glitch/features/ui/util/async-components'; -import BundleContainer from '../containers/bundle_container'; - import { ActionsModal } from './actions_modal'; import AudioModal from './audio_modal'; import { BoostModal } from './boost_modal'; +import Bundle from './bundle'; import { ConfirmationModal, ConfirmDeleteStatusModal, @@ -147,11 +146,11 @@ export default class ModalRoot extends PureComponent { {visible && ( <> - + {(SpecificComponent) => { return ; }} - + diff --git a/app/javascript/flavours/glitch/features/ui/containers/bundle_container.js b/app/javascript/flavours/glitch/features/ui/containers/bundle_container.js deleted file mode 100644 index 6a476fe248..0000000000 --- a/app/javascript/flavours/glitch/features/ui/containers/bundle_container.js +++ /dev/null @@ -1,18 +0,0 @@ -import { connect } from 'react-redux'; - -import { fetchBundleRequest, fetchBundleSuccess, fetchBundleFail } from '../../../actions/bundles'; -import Bundle from '../components/bundle'; - -const mapDispatchToProps = dispatch => ({ - onFetch () { - dispatch(fetchBundleRequest()); - }, - onFetchSuccess () { - dispatch(fetchBundleSuccess()); - }, - onFetchFail (error) { - dispatch(fetchBundleFail(error)); - }, -}); - -export default connect(null, mapDispatchToProps)(Bundle); diff --git a/app/javascript/flavours/glitch/features/ui/index.jsx b/app/javascript/flavours/glitch/features/ui/index.jsx index ddb30ba4cf..f18c44c4c8 100644 --- a/app/javascript/flavours/glitch/features/ui/index.jsx +++ b/app/javascript/flavours/glitch/features/ui/index.jsx @@ -116,7 +116,7 @@ class SwitchingColumnsArea extends PureComponent { forceOnboarding: PropTypes.bool, }; - UNSAFE_componentWillMount () { + componentDidMount () { document.body.classList.toggle('layout-single-column', this.props.singleColumn); document.body.classList.toggle('layout-multiple-columns', !this.props.singleColumn); } diff --git a/app/javascript/flavours/glitch/features/ui/util/focusUtils.ts b/app/javascript/flavours/glitch/features/ui/util/focusUtils.ts index 90bb6d6cab..a728a3c5eb 100644 --- a/app/javascript/flavours/glitch/features/ui/util/focusUtils.ts +++ b/app/javascript/flavours/glitch/features/ui/util/focusUtils.ts @@ -1,5 +1,3 @@ -import { initialState } from '@/flavours/glitch/initial_state'; - interface FocusColumnOptions { index?: number; focusItem?: 'first' | 'first-visible'; @@ -14,7 +12,10 @@ export function focusColumn({ focusItem = 'first', }: FocusColumnOptions = {}) { // Skip the leftmost drawer in multi-column mode - const indexOffset = initialState?.meta.advanced_layout ? 1 : 0; + const isMultiColumnLayout = !!document.querySelector( + 'body.layout-multiple-columns', + ); + const indexOffset = isMultiColumnLayout ? 1 : 0; const column = document.querySelector( `.column:nth-child(${index + indexOffset})`, diff --git a/app/javascript/flavours/glitch/features/ui/util/react_router_helpers.jsx b/app/javascript/flavours/glitch/features/ui/util/react_router_helpers.jsx index ec68b5a4a7..3728e2e2e7 100644 --- a/app/javascript/flavours/glitch/features/ui/util/react_router_helpers.jsx +++ b/app/javascript/flavours/glitch/features/ui/util/react_router_helpers.jsx @@ -5,9 +5,9 @@ import { Switch, Route, useLocation } from 'react-router-dom'; import StackTrace from 'stacktrace-js'; +import Bundle from '../components/bundle'; import BundleColumnError from '../components/bundle_column_error'; import { ColumnLoading } from '../components/column_loading'; -import BundleContainer from '../containers/bundle_container'; // Small wrapper to pass multiColumn to the route components export const WrappedSwitch = ({ multiColumn, children }) => { @@ -80,9 +80,9 @@ export class WrappedRoute extends Component { } return ( - + {Component => {content}} - + ); }; diff --git a/app/javascript/flavours/glitch/selectors/index.js b/app/javascript/flavours/glitch/selectors/index.js index 6cd7846639..770aef7f98 100644 --- a/app/javascript/flavours/glitch/selectors/index.js +++ b/app/javascript/flavours/glitch/selectors/index.js @@ -14,7 +14,7 @@ const getStatusInputSelectors = [ (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]), (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]), getFilters, - (_, { contextType }) => ['detailed', 'bookmarks', 'favourites'].includes(contextType), + (_, { contextType }) => ['detailed', 'bookmarks', 'favourites', 'search'].includes(contextType), ]; function getStatusResultFunction( diff --git a/app/javascript/flavours/glitch/styles/components.scss b/app/javascript/flavours/glitch/styles/components.scss index 3c499069f2..7fcd50278f 100644 --- a/app/javascript/flavours/glitch/styles/components.scss +++ b/app/javascript/flavours/glitch/styles/components.scss @@ -6441,12 +6441,14 @@ a.status-card { inset-inline-start: 0; inset-inline-end: 0; bottom: 0; - align-items: center; - justify-content: space-around; // If set to center, the fullscreen image overlay is misaligned. > div { flex-shrink: 0; overflow: auto; + display: flex; + align-items: center; + justify-content: center; + width: 100%; } } @@ -7642,6 +7644,13 @@ img.modal-warning { grid-template-rows: 1fr 1fr; gap: 2px; + &--layout-1 { + // The size of single images is determined by their + // aspect-ratio, applied via inline style attribute + width: initial; + max-height: 460px; + } + &--layout-2 { & > .media-gallery__item:nth-child(1) { border-end-end-radius: 0; @@ -7985,12 +7994,11 @@ img.modal-warning { overflow: hidden; position: relative; background: $base-shadow-color; - max-width: 100%; + max-height: 460px; border-radius: 8px; box-sizing: border-box; color: $white; display: flex; - align-items: center; outline: 1px solid var(--media-outline-color); outline-offset: -1px; z-index: 2; diff --git a/app/javascript/flavours/glitch/styles_new/mastodon/accounts.scss b/app/javascript/flavours/glitch/styles_new/mastodon/accounts.scss index 421cc04626..03f15cfdff 100644 --- a/app/javascript/flavours/glitch/styles_new/mastodon/accounts.scss +++ b/app/javascript/flavours/glitch/styles_new/mastodon/accounts.scss @@ -113,8 +113,8 @@ } .current { - color: var(--color-bg-inverted); - background: var(--color-text-on-inverted); + color: var(--color-bg-primary); + background: var(--color-text-primary); border-radius: 100px; cursor: default; margin: 0 10px; diff --git a/app/javascript/flavours/glitch/styles_new/mastodon/admin.scss b/app/javascript/flavours/glitch/styles_new/mastodon/admin.scss index 9885511989..aeb7b7464c 100644 --- a/app/javascript/flavours/glitch/styles_new/mastodon/admin.scss +++ b/app/javascript/flavours/glitch/styles_new/mastodon/admin.scss @@ -122,7 +122,11 @@ $content-width: 840px; align-items: center; gap: 6px; padding: 15px; - color: var(--color-text-secondary); + color: color-mix( + in oklab, + var(--color-text-primary), + var(--color-text-secondary) + ); text-decoration: none; transition: all 200ms linear; transition-property: color, background-color; diff --git a/app/javascript/flavours/glitch/styles_new/mastodon/components.scss b/app/javascript/flavours/glitch/styles_new/mastodon/components.scss index 61faaf1f32..60752e24a4 100644 --- a/app/javascript/flavours/glitch/styles_new/mastodon/components.scss +++ b/app/javascript/flavours/glitch/styles_new/mastodon/components.scss @@ -4582,7 +4582,7 @@ a.status-card { z-index: 1; background: radial-gradient( ellipse, - rgb(from var(--color-bg-brand-softer-base) r g b / 23%) 0%, + rgb(from var(--color-bg-brand-base) r g b / 23%) 0%, transparent 60% ); } @@ -6329,12 +6329,14 @@ a.status-card { inset-inline-start: 0; inset-inline-end: 0; bottom: 0; - align-items: center; - justify-content: space-around; // If set to center, the fullscreen image overlay is misaligned. > div { flex-shrink: 0; overflow: auto; + display: flex; + align-items: center; + justify-content: center; + width: 100%; } } @@ -7458,6 +7460,13 @@ img.modal-warning { grid-template-rows: 1fr 1fr; gap: 2px; + &--layout-1 { + // The size of single images is determined by their + // aspect-ratio, applied via inline style attribute + width: initial; + max-height: 460px; + } + &--layout-2 { & > .media-gallery__item:nth-child(1) { border-end-end-radius: 0; @@ -7808,11 +7817,10 @@ img.modal-warning { position: relative; color: var(--color-text-on-media); background: var(--color-bg-media); - max-width: 100%; + max-height: 460px; border-radius: 8px; box-sizing: border-box; display: flex; - align-items: center; outline: 1px solid var(--color-border-media); outline-offset: -1px; z-index: 2; diff --git a/app/javascript/flavours/glitch/styles_new/mastodon/css_variables.scss b/app/javascript/flavours/glitch/styles_new/mastodon/css_variables.scss index ba3319a6b4..b270bd337f 100644 --- a/app/javascript/flavours/glitch/styles_new/mastodon/css_variables.scss +++ b/app/javascript/flavours/glitch/styles_new/mastodon/css_variables.scss @@ -79,7 +79,7 @@ // Utility --color-bg-ambient: var(--color-bg-primary); - --color-bg-elevated: var(--color-grey-800); + --color-bg-elevated: var(--color-bg-primary); --color-bg-inverted: var(--color-grey-50); --color-bg-media-base: var(--color-black); --color-bg-media-strength: 65%; @@ -87,7 +87,7 @@ var(--color-bg-media-base), var(--color-bg-media-strength) )}; - --color-bg-overlay: var(--color-bg-primary); + --color-bg-overlay: var(--color-black); --color-bg-disabled: var(--color-grey-700); // Brand diff --git a/app/javascript/flavours/glitch/utils/links.ts b/app/javascript/flavours/glitch/utils/links.ts new file mode 100644 index 0000000000..02b74bde4d --- /dev/null +++ b/app/javascript/flavours/glitch/utils/links.ts @@ -0,0 +1,88 @@ +import { on } from 'delegated-events'; + +export function setupLinkListeners() { + on('click', 'a[data-confirm]', handleConfirmLink); + + // We don't want to target links with data-confirm here, as those are handled already. + on('click', 'a[data-method]:not([data-confirm])', handleMethodLink); +} + +function handleConfirmLink(event: MouseEvent) { + const anchor = event.currentTarget; + if (!(anchor instanceof HTMLAnchorElement)) { + return; + } + const message = anchor.dataset.confirm; + if (!message || !window.confirm(message)) { + event.preventDefault(); + return; + } + + if (anchor.dataset.method) { + handleMethodLink(event); + } +} + +function handleMethodLink(event: MouseEvent) { + const anchor = event.currentTarget; + if (!(anchor instanceof HTMLAnchorElement)) { + return; + } + + const method = anchor.dataset.method?.toLowerCase(); + if (!method) { + return; + } + event.preventDefault(); + + // Create and submit a form with the specified method. + const form = document.createElement('form'); + form.method = 'post'; + form.action = anchor.href; + + // Add the hidden _method input to simulate other HTTP methods. + const methodInput = document.createElement('input'); + methodInput.type = 'hidden'; + methodInput.name = '_method'; + methodInput.value = method; + form.appendChild(methodInput); + + // Add CSRF token if available for same-origin requests. + const csrf = getCSRFToken(); + if (csrf && !isCrossDomain(anchor.href)) { + const csrfInput = document.createElement('input'); + csrfInput.type = 'hidden'; + csrfInput.name = csrf.param; + csrfInput.value = csrf.token; + form.appendChild(csrfInput); + } + + // The form needs to be in the document to be submitted. + form.style.display = 'none'; + document.body.appendChild(form); + + // We use requestSubmit to ensure any form submit handlers are properly invoked. + form.requestSubmit(); +} + +function getCSRFToken() { + const param = document.querySelector( + 'meta[name="csrf-param"]', + ); + const token = document.querySelector( + 'meta[name="csrf-token"]', + ); + if (param && token) { + return { param: param.content, token: token.content }; + } + return null; +} + +function isCrossDomain(href: string) { + const link = document.createElement('a'); + link.href = href; + return ( + link.protocol !== window.location.protocol || + link.host !== window.location.host + ); +} diff --git a/app/javascript/mastodon/actions/bundles.js b/app/javascript/mastodon/actions/bundles.js deleted file mode 100644 index ecc9c8f7d3..0000000000 --- a/app/javascript/mastodon/actions/bundles.js +++ /dev/null @@ -1,25 +0,0 @@ -export const BUNDLE_FETCH_REQUEST = 'BUNDLE_FETCH_REQUEST'; -export const BUNDLE_FETCH_SUCCESS = 'BUNDLE_FETCH_SUCCESS'; -export const BUNDLE_FETCH_FAIL = 'BUNDLE_FETCH_FAIL'; - -export function fetchBundleRequest(skipLoading) { - return { - type: BUNDLE_FETCH_REQUEST, - skipLoading, - }; -} - -export function fetchBundleSuccess(skipLoading) { - return { - type: BUNDLE_FETCH_SUCCESS, - skipLoading, - }; -} - -export function fetchBundleFail(error, skipLoading) { - return { - type: BUNDLE_FETCH_FAIL, - error, - skipLoading, - }; -} diff --git a/app/javascript/mastodon/api_types/statuses.ts b/app/javascript/mastodon/api_types/statuses.ts index 00418a13d2..1451ea82a0 100644 --- a/app/javascript/mastodon/api_types/statuses.ts +++ b/app/javascript/mastodon/api_types/statuses.ts @@ -51,7 +51,7 @@ export interface ApiPreviewCardJSON { html: string; width: number; height: number; - image: string; + image: string | null; image_description: string; embed_url: string; blurhash: string; diff --git a/app/javascript/mastodon/common.ts b/app/javascript/mastodon/common.ts index e621a24e39..33d2b5ad17 100644 --- a/app/javascript/mastodon/common.ts +++ b/app/javascript/mastodon/common.ts @@ -1,9 +1,5 @@ -import Rails from '@rails/ujs'; +import { setupLinkListeners } from './utils/links'; export function start() { - try { - Rails.start(); - } catch { - // If called twice - } + setupLinkListeners(); } diff --git a/app/javascript/mastodon/components/autosuggest_input.jsx b/app/javascript/mastodon/components/autosuggest_input.jsx index f059356f09..9e342a353a 100644 --- a/app/javascript/mastodon/components/autosuggest_input.jsx +++ b/app/javascript/mastodon/components/autosuggest_input.jsx @@ -159,8 +159,8 @@ export default class AutosuggestInput extends ImmutablePureComponent { this.input.focus(); }; - UNSAFE_componentWillReceiveProps (nextProps) { - if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) { + componentDidUpdate (prevProps) { + if (prevProps.suggestions !== this.props.suggestions && this.props.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) { this.setState({ suggestionsHidden: false }); } } diff --git a/app/javascript/mastodon/components/media_gallery.jsx b/app/javascript/mastodon/components/media_gallery.jsx index 64250236d8..1b0ee6d958 100644 --- a/app/javascript/mastodon/components/media_gallery.jsx +++ b/app/javascript/mastodon/components/media_gallery.jsx @@ -242,11 +242,11 @@ class MediaGallery extends PureComponent { window.removeEventListener('resize', this.handleResize); } - UNSAFE_componentWillReceiveProps (nextProps) { - if (!is(nextProps.media, this.props.media) && nextProps.visible === undefined) { - this.setState({ visible: displayMedia !== 'hide_all' && !nextProps.sensitive || displayMedia === 'show_all' }); - } else if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) { - this.setState({ visible: nextProps.visible }); + componentDidUpdate (prevProps) { + if (!is(prevProps.media, this.props.media) && this.props.visible === undefined) { + this.setState({ visible: displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all' }); + } else if (!is(prevProps.visible, this.props.visible) && this.props.visible !== undefined) { + this.setState({ visible: this.props.visible }); } } diff --git a/app/javascript/mastodon/components/modal_root.jsx b/app/javascript/mastodon/components/modal_root.jsx index 1eae0819af..61ff19256f 100644 --- a/app/javascript/mastodon/components/modal_root.jsx +++ b/app/javascript/mastodon/components/modal_root.jsx @@ -61,14 +61,6 @@ class ModalRoot extends PureComponent { this.history = this.props.history || createBrowserHistory(); } - UNSAFE_componentWillReceiveProps (nextProps) { - if (!!nextProps.children && !this.props.children) { - this.activeElement = document.activeElement; - - this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true)); - } - } - componentDidUpdate (prevProps) { if (!this.props.children && !!prevProps.children) { this.getSiblings().forEach(sibling => sibling.removeAttribute('inert')); @@ -85,9 +77,15 @@ class ModalRoot extends PureComponent { this._handleModalClose(); } + if (this.props.children && !prevProps.children) { + this.activeElement = document.activeElement; + + this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true)); + this._handleModalOpen(); } + if (this.props.children) { this._ensureHistoryBuffer(); } diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 1e746e5f71..15f0b9da30 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -538,9 +538,8 @@ class Status extends ImmutablePureComponent { } else if (status.get('card') && !status.get('quote')) { media = ( ); diff --git a/app/javascript/mastodon/features/blocks/index.jsx b/app/javascript/mastodon/features/blocks/index.jsx index 57f86042e3..2bb3bc1462 100644 --- a/app/javascript/mastodon/features/blocks/index.jsx +++ b/app/javascript/mastodon/features/blocks/index.jsx @@ -38,7 +38,7 @@ class Blocks extends ImmutablePureComponent { multiColumn: PropTypes.bool, }; - UNSAFE_componentWillMount () { + componentDidMount () { this.props.dispatch(fetchBlocks()); } diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx index 1d96427f6b..ad769fe924 100644 --- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx +++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx @@ -61,8 +61,14 @@ class ModifierPickerMenu extends PureComponent { this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1); }; - UNSAFE_componentWillReceiveProps(nextProps) { - if (nextProps.active) { + componentDidMount() { + if (this.props.active) { + this.attachListeners(); + } + } + + componentDidUpdate() { + if (this.props.active) { this.attachListeners(); } else { this.removeListeners(); diff --git a/app/javascript/mastodon/features/favourites/index.jsx b/app/javascript/mastodon/features/favourites/index.jsx index c5c6abe081..245954ef80 100644 --- a/app/javascript/mastodon/features/favourites/index.jsx +++ b/app/javascript/mastodon/features/favourites/index.jsx @@ -41,7 +41,7 @@ class Favourites extends ImmutablePureComponent { intl: PropTypes.object.isRequired, }; - UNSAFE_componentWillMount () { + componentDidMount () { if (!this.props.accountIds) { this.props.dispatch(fetchFavourites(this.props.params.statusId)); } diff --git a/app/javascript/mastodon/features/follow_requests/index.jsx b/app/javascript/mastodon/features/follow_requests/index.jsx index 7d651f2ca6..91648412b5 100644 --- a/app/javascript/mastodon/features/follow_requests/index.jsx +++ b/app/javascript/mastodon/features/follow_requests/index.jsx @@ -45,7 +45,7 @@ class FollowRequests extends ImmutablePureComponent { multiColumn: PropTypes.bool, }; - UNSAFE_componentWillMount () { + componentDidMount () { this.props.dispatch(fetchFollowRequests()); } diff --git a/app/javascript/mastodon/features/list_timeline/index.jsx b/app/javascript/mastodon/features/list_timeline/index.jsx index 9020fd2bee..cc4533ea99 100644 --- a/app/javascript/mastodon/features/list_timeline/index.jsx +++ b/app/javascript/mastodon/features/list_timeline/index.jsx @@ -73,11 +73,10 @@ class ListTimeline extends PureComponent { this.disconnect = dispatch(connectListStream(id)); } - UNSAFE_componentWillReceiveProps (nextProps) { - const { dispatch } = this.props; - const { id } = nextProps.params; + componentDidUpdate (prevProps) { + const { dispatch, params: {id} } = this.props; - if (id !== this.props.params.id) { + if (id !== prevProps.params.id) { if (this.disconnect) { this.disconnect(); this.disconnect = null; diff --git a/app/javascript/mastodon/features/mutes/index.jsx b/app/javascript/mastodon/features/mutes/index.jsx index 5a5711da58..28c76a04e2 100644 --- a/app/javascript/mastodon/features/mutes/index.jsx +++ b/app/javascript/mastodon/features/mutes/index.jsx @@ -40,7 +40,7 @@ class Mutes extends ImmutablePureComponent { multiColumn: PropTypes.bool, }; - UNSAFE_componentWillMount () { + componentDidMount () { this.props.dispatch(fetchMutes()); } diff --git a/app/javascript/mastodon/features/pinned_statuses/index.jsx b/app/javascript/mastodon/features/pinned_statuses/index.jsx index 921e9a6072..786cbeee94 100644 --- a/app/javascript/mastodon/features/pinned_statuses/index.jsx +++ b/app/javascript/mastodon/features/pinned_statuses/index.jsx @@ -34,7 +34,7 @@ class PinnedStatuses extends ImmutablePureComponent { multiColumn: PropTypes.bool, }; - UNSAFE_componentWillMount () { + componentDidMount () { this.props.dispatch(fetchPinnedStatuses()); } diff --git a/app/javascript/mastodon/features/reblogs/index.jsx b/app/javascript/mastodon/features/reblogs/index.jsx index 8bde919a0f..24786b62f0 100644 --- a/app/javascript/mastodon/features/reblogs/index.jsx +++ b/app/javascript/mastodon/features/reblogs/index.jsx @@ -42,7 +42,7 @@ class Reblogs extends ImmutablePureComponent { intl: PropTypes.object.isRequired, }; - UNSAFE_componentWillMount () { + componentDidMount () { if (!this.props.accountIds) { this.props.dispatch(fetchReblogs(this.props.params.statusId)); } diff --git a/app/javascript/mastodon/features/search/index.tsx b/app/javascript/mastodon/features/search/index.tsx index a55255bcde..5b4ed807fa 100644 --- a/app/javascript/mastodon/features/search/index.tsx +++ b/app/javascript/mastodon/features/search/index.tsx @@ -53,7 +53,7 @@ const renderHashtags = (hashtags: HashtagType[]) => const renderStatuses = (statusIds: string[]) => hidePeek(statusIds).map((id) => ( - + )); type SearchType = 'all' | ApiSearchType; @@ -189,7 +189,7 @@ export const SearchResults: React.FC<{ multiColumn: boolean }> = ({ onClickMore={handleSelectStatuses} > {results.statuses.slice(0, INITIAL_DISPLAY).map((id) => ( - + ))} )} diff --git a/app/javascript/mastodon/features/status/components/card.jsx b/app/javascript/mastodon/features/status/components/card.jsx deleted file mode 100644 index 308555b77f..0000000000 --- a/app/javascript/mastodon/features/status/components/card.jsx +++ /dev/null @@ -1,254 +0,0 @@ -import punycode from 'punycode'; - -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import { FormattedMessage } from 'react-intl'; - -import classNames from 'classnames'; - - -import { is } from 'immutable'; -import ImmutablePropTypes from 'react-immutable-proptypes'; - -import DescriptionIcon from '@/material-icons/400-24px/description-fill.svg?react'; -import OpenInNewIcon from '@/material-icons/400-24px/open_in_new.svg?react'; -import PlayArrowIcon from '@/material-icons/400-24px/play_arrow-fill.svg?react'; -import { Blurhash } from 'mastodon/components/blurhash'; -import { Icon } from 'mastodon/components/icon'; -import { MoreFromAuthor } from 'mastodon/components/more_from_author'; -import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; -import { useBlurhash } from 'mastodon/initial_state'; - -const IDNA_PREFIX = 'xn--'; - -const decodeIDNA = domain => { - return domain - .split('.') - .map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part) - .join('.'); -}; - -const getHostname = url => { - const parser = document.createElement('a'); - parser.href = url; - return parser.hostname; -}; - -const domParser = new DOMParser(); - -const handleIframeUrl = (html, url, providerName) => { - const document = domParser.parseFromString(html, 'text/html').documentElement; - const iframe = document.querySelector('iframe'); - const startTime = new URL(url).searchParams.get('t') - - if (iframe) { - const iframeUrl = new URL(iframe.src) - - iframeUrl.searchParams.set('autoplay', 1) - iframeUrl.searchParams.set('auto_play', 1) - - if (startTime && providerName === "YouTube") iframeUrl.searchParams.set('start', startTime) - - iframe.src = iframeUrl.href - - // DOM parser creates html/body elements around original HTML fragment, - // so we need to get innerHTML out of the body and not the entire document - return document.querySelector('body').innerHTML; - } - - return html; -}; - -export default class Card extends PureComponent { - - static propTypes = { - card: ImmutablePropTypes.map, - onOpenMedia: PropTypes.func.isRequired, - sensitive: PropTypes.bool, - }; - - state = { - previewLoaded: false, - embedded: false, - revealed: !this.props.sensitive, - }; - - UNSAFE_componentWillReceiveProps (nextProps) { - if (!is(this.props.card, nextProps.card)) { - this.setState({ embedded: false, previewLoaded: false }); - } - - if (this.props.sensitive !== nextProps.sensitive) { - this.setState({ revealed: !nextProps.sensitive }); - } - } - - componentDidMount () { - window.addEventListener('resize', this.handleResize, { passive: true }); - } - - componentWillUnmount () { - window.removeEventListener('resize', this.handleResize); - } - - handleEmbedClick = () => { - this.setState({ embedded: true }); - }; - - handleExternalLinkClick = (e) => { - e.stopPropagation(); - }; - - setRef = c => { - this.node = c; - }; - - handleImageLoad = () => { - this.setState({ previewLoaded: true }); - }; - - handleReveal = e => { - e.preventDefault(); - e.stopPropagation(); - this.setState({ revealed: true }); - }; - - renderVideo () { - const { card } = this.props; - const content = { __html: handleIframeUrl(card.get('html'), card.get('url'), card.get('provider_name')) }; - - return ( -
- ); - } - - render () { - const { card } = this.props; - const { embedded, revealed } = this.state; - - if (card === null) { - return null; - } - - const provider = card.get('provider_name').length === 0 ? decodeIDNA(getHostname(card.get('url'))) : card.get('provider_name'); - const interactive = card.get('type') === 'video'; - const language = card.get('language') || ''; - const largeImage = (card.get('image')?.length > 0 && card.get('width') > card.get('height')) || interactive; - const showAuthor = !!card.getIn(['authors', 0, 'accountId']); - - const description = ( -
- - {provider} - {card.get('published_at') && <> · } - - - {card.get('title')} - - {!showAuthor && (card.get('author_name').length > 0 ? {card.get('author_name')} }} /> : {card.get('description')})} -
- ); - - const thumbnailStyle = { - visibility: revealed ? null : 'hidden', - }; - - if (largeImage && card.get('type') === 'video') { - thumbnailStyle.aspectRatio = `16 / 9`; - } else if (largeImage) { - thumbnailStyle.aspectRatio = '1.91 / 1'; - } else { - thumbnailStyle.aspectRatio = 1; - } - - let embed; - - let canvas = ( - - ); - - const thumbnailDescription = card.get('image_description'); - const thumbnail = {thumbnailDescription}; - - let spoilerButton = ( - - ); - - spoilerButton = ( -
- {spoilerButton} -
- ); - - if (interactive) { - if (embedded) { - embed = this.renderVideo(); - } else { - embed = ( -
- {canvas} - {thumbnail} - - {revealed ? ( -
-
- - -
-
- ) : spoilerButton} -
- ); - } - - return ( -
- {embed} - {description} -
- ); - } else if (card.get('image')) { - embed = ( -
- {canvas} - {thumbnail} -
- ); - } else { - embed = ( -
- -
- ); - } - - return ( - <> - - {embed} - {description} - - - {showAuthor && } - - ); - } - -} diff --git a/app/javascript/mastodon/features/status/components/card.tsx b/app/javascript/mastodon/features/status/components/card.tsx new file mode 100644 index 0000000000..c6e0c1655a --- /dev/null +++ b/app/javascript/mastodon/features/status/components/card.tsx @@ -0,0 +1,316 @@ +import { useCallback, useId, useState } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; + +import punycode from 'punycode/'; + +import DescriptionIcon from '@/material-icons/400-24px/description-fill.svg?react'; +import OpenInNewIcon from '@/material-icons/400-24px/open_in_new.svg?react'; +import PlayArrowIcon from '@/material-icons/400-24px/play_arrow-fill.svg?react'; +import { Blurhash } from 'mastodon/components/blurhash'; +import { Icon } from 'mastodon/components/icon'; +import { MoreFromAuthor } from 'mastodon/components/more_from_author'; +import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; +import { useBlurhash } from 'mastodon/initial_state'; +import type { Card as CardType } from 'mastodon/models/status'; + +const IDNA_PREFIX = 'xn--'; + +const decodeIDNA = (domain: string) => { + return domain + .split('.') + .map((part) => + part.startsWith(IDNA_PREFIX) + ? punycode.decode(part.slice(IDNA_PREFIX.length)) + : part, + ) + .join('.'); +}; + +const getHostname = (url: string) => { + const parser = document.createElement('a'); + parser.href = url; + return parser.hostname; +}; + +const domParser = new DOMParser(); + +const handleIframeUrl = (html: string, url: string, providerName: string) => { + const document = domParser.parseFromString(html, 'text/html').documentElement; + const iframe = document.querySelector('iframe'); + const startTime = new URL(url).searchParams.get('t'); + + if (iframe) { + const iframeUrl = new URL(iframe.src); + + iframeUrl.searchParams.set('autoplay', '1'); + iframeUrl.searchParams.set('auto_play', '1'); + + if (startTime && providerName === 'YouTube') + iframeUrl.searchParams.set('start', startTime); + + iframe.src = iframeUrl.href; + + // DOM parser creates html/body elements around original HTML fragment, + // so we need to get innerHTML out of the body and not the entire document + return document.querySelector('body')?.innerHTML ?? ''; + } + + return html; +}; + +interface CardProps { + card: CardType | null; + sensitive?: boolean; +} + +const CardVideo: React.FC> = ({ card }) => ( +
+); + +const Card: React.FC = ({ card, sensitive }) => { + const [previewLoaded, setPreviewLoaded] = useState(false); + const [embedded, setEmbedded] = useState(false); + const [revealed, setRevealed] = useState(!sensitive); + + const handleEmbedClick = useCallback(() => { + setEmbedded(true); + }, []); + + const handleExternalLinkClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + }, []); + + const handleImageLoad = useCallback(() => { + setPreviewLoaded(true); + }, []); + + const handleReveal = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setRevealed(true); + }, []); + + const spoilerButtonId = useId(); + + if (card === null) { + return null; + } + + const provider = + card.get('provider_name').length === 0 + ? decodeIDNA(getHostname(card.get('url'))) + : card.get('provider_name'); + const interactive = card.get('type') === 'video'; + const language = card.get('language') || ''; + const hasImage = (card.get('image')?.length ?? 0) > 0; + const largeImage = + (hasImage && card.get('width') > card.get('height')) || interactive; + const showAuthor = !!card.getIn(['authors', 0, 'accountId']); + + const description = ( +
+ + {provider} + {card.get('published_at') && ( + <> + {' '} + · + + )} + + + + {card.get('title')} + + + {!showAuthor && + (card.get('author_name').length > 0 ? ( + + {card.get('author_name')} }} + /> + + ) : ( + + {card.get('description')} + + ))} +
+ ); + + const thumbnailStyle: React.CSSProperties = { + visibility: revealed ? undefined : 'hidden', + aspectRatio: '1', + }; + + if (largeImage && card.get('type') === 'video') { + thumbnailStyle.aspectRatio = `16 / 9`; + } else if (largeImage) { + thumbnailStyle.aspectRatio = '1.91 / 1'; + } + + let embed; + + const canvas = ( + + ); + + const thumbnailDescription = card.get('image_description'); + const thumbnail = ( + {thumbnailDescription} + ); + + const spoilerButton = ( +
+ +
+ ); + + if (interactive) { + if (embedded) { + embed = ; + } else { + embed = ( +
+ {canvas} + {thumbnail} + + {revealed ? ( +
+
+ + + + +
+
+ ) : ( + spoilerButton + )} +
+ ); + } + + return ( +
+ {embed} + + {description} + +
+ ); + } else if (card.get('image')) { + embed = ( +
+ {canvas} + {thumbnail} +
+ ); + } else { + embed = ( +
+ +
+ ); + } + + return ( + <> + + {embed} + {description} + + + {showAuthor && ( + + )} + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default Card; diff --git a/app/javascript/mastodon/features/status/components/detailed_status.tsx b/app/javascript/mastodon/features/status/components/detailed_status.tsx index b0c66b9338..1dee2e5147 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.tsx +++ b/app/javascript/mastodon/features/status/components/detailed_status.tsx @@ -262,8 +262,8 @@ export const DetailedStatus: React.FC<{ } else if (status.get('card') && !status.get('quote')) { media = ( ); diff --git a/app/javascript/mastodon/features/ui/components/bundle.jsx b/app/javascript/mastodon/features/ui/components/bundle.jsx index 15c4220b34..57143f1571 100644 --- a/app/javascript/mastodon/features/ui/components/bundle.jsx +++ b/app/javascript/mastodon/features/ui/components/bundle.jsx @@ -2,7 +2,6 @@ import PropTypes from 'prop-types'; import { PureComponent } from 'react'; const emptyComponent = () => null; -const noop = () => { }; class Bundle extends PureComponent { @@ -12,18 +11,12 @@ class Bundle extends PureComponent { error: PropTypes.func, children: PropTypes.func.isRequired, renderDelay: PropTypes.number, - onFetch: PropTypes.func, - onFetchSuccess: PropTypes.func, - onFetchFail: PropTypes.func, }; static defaultProps = { loading: emptyComponent, error: emptyComponent, renderDelay: 0, - onFetch: noop, - onFetchSuccess: noop, - onFetchFail: noop, }; static cache = new Map; @@ -33,13 +26,13 @@ class Bundle extends PureComponent { forceRender: false, }; - UNSAFE_componentWillMount() { + componentDidMount() { this.load(this.props); } - UNSAFE_componentWillReceiveProps(nextProps) { - if (nextProps.fetchComponent !== this.props.fetchComponent) { - this.load(nextProps); + componentDidUpdate(prevProps) { + if (prevProps.fetchComponent !== this.props.fetchComponent) { + this.load(this.props); } } @@ -50,7 +43,7 @@ class Bundle extends PureComponent { } load = (props) => { - const { fetchComponent, onFetch, onFetchSuccess, onFetchFail, renderDelay } = props || this.props; + const { fetchComponent, renderDelay } = props || this.props; const cachedMod = Bundle.cache.get(fetchComponent); if (fetchComponent === undefined) { @@ -58,11 +51,8 @@ class Bundle extends PureComponent { return Promise.resolve(); } - onFetch(); - if (cachedMod) { this.setState({ mod: cachedMod.default }); - onFetchSuccess(); return Promise.resolve(); } @@ -77,11 +67,9 @@ class Bundle extends PureComponent { .then((mod) => { Bundle.cache.set(fetchComponent, mod); this.setState({ mod: mod.default }); - onFetchSuccess(); }) .catch((error) => { this.setState({ mod: null }); - onFetchFail(error); }); }; diff --git a/app/javascript/mastodon/features/ui/components/columns_area.jsx b/app/javascript/mastodon/features/ui/components/columns_area.jsx index 77b95d526a..753f7e9ac3 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.jsx +++ b/app/javascript/mastodon/features/ui/components/columns_area.jsx @@ -5,7 +5,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { scrollRight } from '../../../scroll'; -import BundleContainer from '../containers/bundle_container'; import { Compose, Notifications, @@ -21,6 +20,7 @@ import { } from '../util/async-components'; import { useColumnsContext } from '../util/columns_context'; +import Bundle from './bundle'; import BundleColumnError from './bundle_column_error'; import { ColumnLoading } from './column_loading'; import { ComposePanel, RedirectToMobileComposeIfNeeded } from './compose_panel'; @@ -145,9 +145,9 @@ export default class ColumnsArea extends ImmutablePureComponent { const other = params && params.other ? params.other : {}; return ( - + {SpecificComponent => } - + ); })} diff --git a/app/javascript/mastodon/features/ui/components/modal_root.jsx b/app/javascript/mastodon/features/ui/components/modal_root.jsx index 9afda83908..6f9b23042d 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.jsx +++ b/app/javascript/mastodon/features/ui/components/modal_root.jsx @@ -21,11 +21,10 @@ import { AnnualReportModal, } from 'mastodon/features/ui/util/async-components'; -import BundleContainer from '../containers/bundle_container'; - import { ActionsModal } from './actions_modal'; import AudioModal from './audio_modal'; import { BoostModal } from './boost_modal'; +import Bundle from './bundle'; import { ConfirmationModal, ConfirmDeleteStatusModal, @@ -136,11 +135,11 @@ export default class ModalRoot extends PureComponent { {visible && ( <> - + {(SpecificComponent) => { return ; }} - + diff --git a/app/javascript/mastodon/features/ui/containers/bundle_container.js b/app/javascript/mastodon/features/ui/containers/bundle_container.js deleted file mode 100644 index 6a476fe248..0000000000 --- a/app/javascript/mastodon/features/ui/containers/bundle_container.js +++ /dev/null @@ -1,18 +0,0 @@ -import { connect } from 'react-redux'; - -import { fetchBundleRequest, fetchBundleSuccess, fetchBundleFail } from '../../../actions/bundles'; -import Bundle from '../components/bundle'; - -const mapDispatchToProps = dispatch => ({ - onFetch () { - dispatch(fetchBundleRequest()); - }, - onFetchSuccess () { - dispatch(fetchBundleSuccess()); - }, - onFetchFail (error) { - dispatch(fetchBundleFail(error)); - }, -}); - -export default connect(null, mapDispatchToProps)(Bundle); diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index 209e4b4a87..476c25e32d 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -108,7 +108,7 @@ class SwitchingColumnsArea extends PureComponent { forceOnboarding: PropTypes.bool, }; - UNSAFE_componentWillMount () { + componentDidMount () { document.body.classList.toggle('layout-single-column', this.props.singleColumn); document.body.classList.toggle('layout-multiple-columns', !this.props.singleColumn); } diff --git a/app/javascript/mastodon/features/ui/util/focusUtils.ts b/app/javascript/mastodon/features/ui/util/focusUtils.ts index e46ede3553..a728a3c5eb 100644 --- a/app/javascript/mastodon/features/ui/util/focusUtils.ts +++ b/app/javascript/mastodon/features/ui/util/focusUtils.ts @@ -1,5 +1,3 @@ -import { initialState } from '@/mastodon/initial_state'; - interface FocusColumnOptions { index?: number; focusItem?: 'first' | 'first-visible'; @@ -14,7 +12,10 @@ export function focusColumn({ focusItem = 'first', }: FocusColumnOptions = {}) { // Skip the leftmost drawer in multi-column mode - const indexOffset = initialState?.meta.advanced_layout ? 1 : 0; + const isMultiColumnLayout = !!document.querySelector( + 'body.layout-multiple-columns', + ); + const indexOffset = isMultiColumnLayout ? 1 : 0; const column = document.querySelector( `.column:nth-child(${index + indexOffset})`, diff --git a/app/javascript/mastodon/features/ui/util/react_router_helpers.jsx b/app/javascript/mastodon/features/ui/util/react_router_helpers.jsx index ec68b5a4a7..3728e2e2e7 100644 --- a/app/javascript/mastodon/features/ui/util/react_router_helpers.jsx +++ b/app/javascript/mastodon/features/ui/util/react_router_helpers.jsx @@ -5,9 +5,9 @@ import { Switch, Route, useLocation } from 'react-router-dom'; import StackTrace from 'stacktrace-js'; +import Bundle from '../components/bundle'; import BundleColumnError from '../components/bundle_column_error'; import { ColumnLoading } from '../components/column_loading'; -import BundleContainer from '../containers/bundle_container'; // Small wrapper to pass multiColumn to the route components export const WrappedSwitch = ({ multiColumn, children }) => { @@ -80,9 +80,9 @@ export class WrappedRoute extends Component { } return ( - + {Component => {content}} - + ); }; diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index fcf7e3134e..e693387b12 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -28,6 +28,7 @@ "account.disable_notifications": "@{name}さんの投稿時の通知を停止", "account.domain_blocking": "ブロックしているドメイン", "account.edit_profile": "プロフィール編集", + "account.edit_profile_short": "編集", "account.enable_notifications": "@{name}さんの投稿時に通知", "account.endorse": "プロフィールで紹介する", "account.familiar_followers_many": "{name1}、{name2}、他{othersCount, plural, one {one other you know} other {# others you know}}人のユーザーにフォローされています", @@ -172,6 +173,8 @@ "column.edit_list": "リストを編集", "column.favourites": "お気に入り", "column.firehose": "リアルタイムフィード", + "column.firehose_local": "このサーバーのリアルタイムフィード", + "column.firehose_singular": "リアルタイムフィード", "column.follow_requests": "フォローリクエスト", "column.home": "ホーム", "column.list_members": "リストのメンバーを管理", @@ -251,6 +254,7 @@ "confirmations.remove_from_followers.message": "{name}さんはあなたをフォローしなくなります。本当によろしいですか?", "confirmations.remove_from_followers.title": "フォロワーを削除しますか?", "confirmations.revoke_quote.confirm": "投稿を削除", + "confirmations.revoke_quote.message": "この操作は元に戻せません。", "confirmations.revoke_quote.title": "投稿を削除しますか?", "confirmations.unblock.confirm": "ブロック解除", "confirmations.unblock.title": "@{name}さんのブロックを解除しますか?", @@ -477,6 +481,7 @@ "keyboard_shortcuts.home": "ホームタイムラインを開く", "keyboard_shortcuts.hotkey": "ホットキー", "keyboard_shortcuts.legend": "この一覧を表示", + "keyboard_shortcuts.load_more": "「もっと見る」ボタンに移動", "keyboard_shortcuts.local": "ローカルタイムラインを開く", "keyboard_shortcuts.mention": "メンション", "keyboard_shortcuts.muted": "ミュートしたユーザーのリストを開く", @@ -497,6 +502,7 @@ "keyboard_shortcuts.translate": "投稿を翻訳する", "keyboard_shortcuts.unfocus": "投稿の入力欄・検索欄から離れる", "keyboard_shortcuts.up": "カラム内一つ上に移動", + "learn_more_link.got_it": "了解", "learn_more_link.learn_more": "もっと見る", "lightbox.close": "閉じる", "lightbox.next": "次", @@ -611,6 +617,7 @@ "notification.moderation_warning.action_suspend": "あなたのアカウントは停止されました。", "notification.own_poll": "アンケートが終了しました", "notification.poll": "投票したアンケートが終了しました", + "notification.quoted_update": "あなたが引用した投稿を {name} が編集しました", "notification.reblog": "{name}さんがあなたの投稿をブーストしました", "notification.reblog.name_and_others_with_link": "{name}さんとほか{count, plural, other {#人}}がブーストしました", "notification.relationships_severance_event": "{name} との関係が失われました", @@ -753,6 +760,7 @@ "relative_time.minutes": "{number}分前", "relative_time.seconds": "{number}秒前", "relative_time.today": "今日", + "remove_quote_hint.button_label": "了解", "reply_indicator.attachments": "{count, plural, other {#件のメディア}}", "reply_indicator.cancel": "キャンセル", "reply_indicator.poll": "アンケート", diff --git a/app/javascript/mastodon/locales/lad.json b/app/javascript/mastodon/locales/lad.json index cab0580e1a..9e6f503983 100644 --- a/app/javascript/mastodon/locales/lad.json +++ b/app/javascript/mastodon/locales/lad.json @@ -31,6 +31,8 @@ "account.edit_profile_short": "Edita", "account.enable_notifications": "Avizame kuando @{name} publike", "account.endorse": "Avalia en profil", + "account.familiar_followers_one": "Segido por {name1}", + "account.familiar_followers_two": "Segido por {name1} i {name2}", "account.featured": "Avaliado", "account.featured.accounts": "Profiles", "account.featured.hashtags": "Etiketas", @@ -46,6 +48,7 @@ "account.followers": "Suivantes", "account.followers.empty": "Por agora dingun no sige a este utilizador.", "account.followers_counter": "{count, plural, one {{counter} suivante} other {{counter} suivantes}}", + "account.followers_you_know_counter": "{counter} ke koneses", "account.following": "Sigiendo", "account.following_counter": "{count, plural, other {Sigiendo a {counter}}}", "account.follows.empty": "Este utilizador ainda no sige a dingun.", @@ -157,6 +160,7 @@ "column.edit_list": "Edita lista", "column.favourites": "Te plazen", "column.firehose": "Linyas en bivo", + "column.firehose_singular": "Linya en bivo", "column.follow_requests": "Solisitudes de segimiento", "column.home": "Linya prinsipala", "column.lists": "Listas", @@ -351,6 +355,7 @@ "follow_suggestions.who_to_follow": "A ken segir", "followed_tags": "Etiketas segidas", "footer.about": "Sovre mozotros", + "footer.about_this_server": "Sovre mozotros", "footer.directory": "Katalogo de profiles", "footer.get_app": "Abasha aplikasyon", "footer.keyboard_shortcuts": "Akortamientos de klaviatura", @@ -522,6 +527,7 @@ "notification.label.mention": "Enmenta", "notification.label.private_mention": "Enmentadura privada", "notification.label.private_reply": "Repuesta privada", + "notification.label.quote": "{name} sito tu publikasyon", "notification.label.reply": "Arisponde", "notification.mention": "Enmenta", "notification.mentioned_you": "{name} te enmento", diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json index f88f4b6681..8f2d33c5fa 100644 --- a/app/javascript/mastodon/locales/pl.json +++ b/app/javascript/mastodon/locales/pl.json @@ -173,6 +173,7 @@ "column.edit_list": "Edytuj listę", "column.favourites": "Ulubione", "column.firehose": "Aktualności", + "column.firehose_singular": "Na żywo", "column.follow_requests": "Prośby o obserwację", "column.home": "Strona główna", "column.list_members": "Zarządzaj osobami na liście", diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json index a9c8bbfcb7..2bad3ca2b9 100644 --- a/app/javascript/mastodon/locales/pt-BR.json +++ b/app/javascript/mastodon/locales/pt-BR.json @@ -45,7 +45,7 @@ "account.follow_request": "Pedir para seguir", "account.follow_request_cancel": "Cancelar solicitação", "account.follow_request_cancel_short": "Cancelar", - "account.follow_request_short": "Solicitação", + "account.follow_request_short": "Solicitar", "account.followers": "Seguidores", "account.followers.empty": "Nada aqui.", "account.followers_counter": "{count, plural, one {{counter} seguidor} other {{counter} seguidores}}", @@ -130,18 +130,18 @@ "annual_report.summary.most_used_hashtag.none": "Nenhuma", "annual_report.summary.new_posts.new_posts": "novas publicações", "annual_report.summary.percentile.text": "Isso lhe coloca no topode usuários de {domain}.", - "annual_report.summary.percentile.we_wont_tell_bernie": "Não contaremos à Bernie.", + "annual_report.summary.percentile.we_wont_tell_bernie": "Não contaremos ao Bernie.", "annual_report.summary.thanks": "Obrigada por fazer parte do Mastodon!", "attachments_list.unprocessed": "(não processado)", "audio.hide": "Ocultar áudio", - "block_modal.remote_users_caveat": "Pediremos ao servidor {domain} que respeite sua decisão. No entanto, a conformidade não é garantida, já que alguns servidores podem lidar com bloqueios de maneira diferente. As postagens públicas ainda podem estar visíveis para usuários não logados.", + "block_modal.remote_users_caveat": "Pediremos ao servidor {domain} que respeite sua decisão. No entanto, a conformidade não é garantida, já que alguns servidores podem lidar com bloqueios de maneira diferente. As publicações públicas ainda podem estar visíveis para usuários não logados.", "block_modal.show_less": "Mostrar menos", "block_modal.show_more": "Mostrar mais", - "block_modal.they_cant_mention": "Eles não podem mencionar ou seguir você.", - "block_modal.they_cant_see_posts": "Eles não podem ver suas postagens e você não verá as deles.", - "block_modal.they_will_know": "Eles podem ver que estão bloqueados.", + "block_modal.they_cant_mention": "Não poderá mencionar ou seguir você.", + "block_modal.they_cant_see_posts": "Não poderá ver suas publicações e você não verá as dele/a.", + "block_modal.they_will_know": "Poderá ver que você bloqueou.", "block_modal.title": "Bloquear usuário?", - "block_modal.you_wont_see_mentions": "Você não verá publicações que os mencionem.", + "block_modal.you_wont_see_mentions": "Você não verá publicações que mencionem o usuário.", "boost_modal.combo": "Pressione {combo} para pular isso na próxima vez", "boost_modal.reblog": "Impulsionar a publicação?", "boost_modal.undo_reblog": "Retirar o impulsionamento do post?", @@ -157,8 +157,8 @@ "bundle_modal_error.close": "Fechar", "bundle_modal_error.message": "Algo deu errado ao carregar esta tela.", "bundle_modal_error.retry": "Tente novamente", - "carousel.current": "Deslize {current, number} / {max, number}", - "carousel.slide": "Deslize {current, number} of {max, number}", + "carousel.current": "Slide {current, number} / {max, number}", + "carousel.slide": "Slide {current, number} of {max, number}", "closed_registrations.other_server_instructions": "Como o Mastodon é descentralizado, você pode criar uma conta em outro servidor e ainda pode interagir com este.", "closed_registrations_modal.description": "Não é possível criar uma conta em {domain} no momento, mas atente que você não precisa de uma conta especificamente em {domain} para usar o Mastodon.", "closed_registrations_modal.find_another_server": "Encontrar outro servidor", @@ -196,12 +196,12 @@ "community.column_settings.local_only": "Somente local", "community.column_settings.media_only": "Somente mídia", "community.column_settings.remote_only": "Somente global", - "compose.error.blank_post": "A postagem não pode estar em branco.", + "compose.error.blank_post": "A publicação não pode estar em branco.", "compose.language.change": "Alterar idioma", "compose.language.search": "Pesquisar idiomas...", "compose.published.body": "Publicado.", "compose.published.open": "Abrir", - "compose.saved.body": "Postagem salva.", + "compose.saved.body": "Publicação salva.", "compose_form.direct_message_warning_learn_more": "Saiba mais", "compose_form.encryption_warning": "As publicações no Mastodon não são criptografadas de ponta-a-ponta. Não compartilhe nenhuma informação sensível no Mastodon.", "compose_form.hashtag_warning": "Esta publicação não será exibida sob nenhuma hashtag, já que não é pública. Apenas postagens públicas podem ser pesquisadas por meio de hashtags.", @@ -217,7 +217,7 @@ "compose_form.poll.type": "Estilo", "compose_form.publish": "Publicar", "compose_form.reply": "Responder", - "compose_form.save_changes": "Atualização", + "compose_form.save_changes": "Atualizar", "compose_form.spoiler.marked": "Com Aviso de Conteúdo", "compose_form.spoiler.unmarked": "Sem Aviso de Conteúdo", "compose_form.spoiler_placeholder": "Aviso de conteúdo (opcional)", @@ -231,11 +231,11 @@ "confirmations.delete_list.title": "Excluir lista?", "confirmations.discard_draft.confirm": "Descartar e continuar", "confirmations.discard_draft.edit.cancel": "Continuar editando", - "confirmations.discard_draft.edit.message": "Continuar vai descartar quaisquer mudanças feitas ao post sendo editado.", - "confirmations.discard_draft.edit.title": "Descartar mudanças no seu post?", + "confirmations.discard_draft.edit.message": "Continuar vai descartar quaisquer mudanças feitas à publicação sendo editada.", + "confirmations.discard_draft.edit.title": "Descartar mudanças na sua publicação?", "confirmations.discard_draft.post.cancel": "Continuar rascunho", "confirmations.discard_draft.post.message": "Continuar eliminará a publicação que está sendo elaborada no momento.", - "confirmations.discard_draft.post.title": "Eliminar seu esboço de publicação?", + "confirmations.discard_draft.post.title": "Eliminar seu rascunho de publicação?", "confirmations.discard_edit_media.confirm": "Descartar", "confirmations.discard_edit_media.message": "Há mudanças não salvas na descrição ou pré-visualização da mídia. Descartar assim mesmo?", "confirmations.follow_to_list.confirm": "Seguir e adicionar à lista", @@ -246,13 +246,13 @@ "confirmations.logout.title": "Sair da sessão?", "confirmations.missing_alt_text.confirm": "Adicione texto alternativo", "confirmations.missing_alt_text.message": "Seu post contém mídia sem texto alternativo. Adicionar descrições ajuda a tornar seu conteúdo acessível para mais pessoas.", - "confirmations.missing_alt_text.secondary": "Postar mesmo assim", + "confirmations.missing_alt_text.secondary": "Publicar mesmo assim", "confirmations.missing_alt_text.title": "Adicionar texto alternativo?", "confirmations.mute.confirm": "Silenciar", "confirmations.private_quote_notify.cancel": "Voltar à edição", - "confirmations.private_quote_notify.confirm": "Publicar postagem", + "confirmations.private_quote_notify.confirm": "Publicar citação", "confirmations.private_quote_notify.do_not_show_again": "Não me mostre esta mensagem novamente", - "confirmations.private_quote_notify.message": "A pessoa que está citando e outras menções serão notificadas e poderão ver sua postagem, mesmo que não sigam você.", + "confirmations.private_quote_notify.message": "A pessoa que está sendo citada e outras mencionadas serão notificadas e poderão ver sua publicação, mesmo que não sigam você.", "confirmations.private_quote_notify.title": "Compartilhar com seguidores e usuários mencionados?", "confirmations.quiet_post_quote_info.dismiss": "Não me lembrar novamente", "confirmations.quiet_post_quote_info.got_it": "Entendi", @@ -265,14 +265,14 @@ "confirmations.remove_from_followers.message": "{name} vai parar de te seguir. Tem certeza de que deseja continuar?", "confirmations.remove_from_followers.title": "Remover seguidor?", "confirmations.revoke_quote.confirm": "Remover publicação", - "confirmations.revoke_quote.message": "Essa ação não pode ser desfeita.", + "confirmations.revoke_quote.message": "Esta ação não pode ser desfeita.", "confirmations.revoke_quote.title": "Remover publicação?", "confirmations.unblock.confirm": "Desbloquear", "confirmations.unblock.title": "Desbloquear {name}?", "confirmations.unfollow.confirm": "Deixar de seguir", "confirmations.unfollow.title": "Deixar de seguir {name}?", "confirmations.withdraw_request.confirm": "Retirar solicitação", - "confirmations.withdraw_request.title": "Cancelar solicitação para seguir {name}?", + "confirmations.withdraw_request.title": "Retirar solicitação para seguir {name}?", "content_warning.hide": "Ocultar publicação", "content_warning.show": "Mostrar mesmo assim", "content_warning.show_more": "Mostrar mais", @@ -369,9 +369,9 @@ "explore.trending_links": "Notícias", "explore.trending_statuses": "Publicações", "explore.trending_tags": "Hashtags", - "featured_carousel.current": "Postar {current, number} / {max, number}", + "featured_carousel.current": "Publicação {current, number} / {max, number}", "featured_carousel.header": "{count, plural, one {Postagem fixada} other {Postagens fixadas}}", - "featured_carousel.slide": "Postar {current, number} of {max, number}", + "featured_carousel.slide": "Publicação {current, number} of {max, number}", "filter_modal.added.context_mismatch_explanation": "Esta categoria de filtro não se aplica ao contexto no qual você acessou esta publicação. Se quiser que a publicação seja filtrada nesse contexto também, você terá que editar o filtro.", "filter_modal.added.context_mismatch_title": "Incompatibilidade de contexto!", "filter_modal.added.expired_explanation": "Esta categoria de filtro expirou, você precisará alterar a data de expiração para aplicar.", diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json index cbe6a1e56f..c198ff8e35 100644 --- a/app/javascript/mastodon/locales/zh-TW.json +++ b/app/javascript/mastodon/locales/zh-TW.json @@ -5,7 +5,7 @@ "about.disclaimer": "Mastodon 是一個自由的開源軟體,是 Mastodon gGmbH 之註冊商標。", "about.domain_blocks.no_reason_available": "無法存取的原因", "about.domain_blocks.preamble": "Mastodon 基本上允許您瀏覽聯邦宇宙中任何伺服器的內容並與使用者互動。以下是於本伺服器上設定之例外。", - "about.domain_blocks.silenced.explanation": "一般來說您不會看到來自這個伺服器的個人檔案與內容,除非您明確地打開或著跟隨此個人檔案。", + "about.domain_blocks.silenced.explanation": "一般來說您不會看到來自這個伺服器的個人檔案與內容,除非您明確地檢視或著跟隨此個人檔案。", "about.domain_blocks.silenced.title": "已受限", "about.domain_blocks.suspended.explanation": "來自此伺服器的資料都不會被處理、儲存或交換,也無法和此伺服器上的使用者互動與交流。", "about.domain_blocks.suspended.title": "已停權", @@ -90,7 +90,7 @@ "account.unmute": "解除靜音 @{name}", "account.unmute_notifications_short": "解除靜音推播通知", "account.unmute_short": "解除靜音", - "account_note.placeholder": "按此新增備註", + "account_note.placeholder": "點擊以新增備註", "admin.dashboard.daily_retention": "註冊後使用者存留率(日)", "admin.dashboard.monthly_retention": "註冊後使用者存留率(月)", "admin.dashboard.retention.average": "平均", @@ -138,10 +138,10 @@ "block_modal.show_less": "減少顯示", "block_modal.show_more": "顯示更多", "block_modal.they_cant_mention": "他們無法提及或跟隨您。", - "block_modal.they_cant_see_posts": "他們無法讀取您的嘟文,且您不會見到他們。", + "block_modal.they_cant_see_posts": "他們無法讀取您的嘟文,且您不會見到他們的嘟文。", "block_modal.they_will_know": "他們能見到他們已被封鎖。", "block_modal.title": "是否封鎖該使用者?", - "block_modal.you_wont_see_mentions": "您不會見到提及他們的嘟文。", + "block_modal.you_wont_see_mentions": "您將不會見到提及他們的嘟文。", "boost_modal.combo": "您下次可以按 {combo} 跳過", "boost_modal.reblog": "是否要轉嘟?", "boost_modal.undo_reblog": "是否要取消轉嘟?", @@ -181,7 +181,7 @@ "column.home": "首頁", "column.list_members": "管理列表成員", "column.lists": "列表", - "column.mutes": "已靜音的使用者", + "column.mutes": "已靜音使用者", "column.notifications": "推播通知", "column.pins": "釘選的嘟文", "column.public": "聯邦時間軸", @@ -193,9 +193,9 @@ "column_header.show_settings": "顯示設定", "column_header.unpin": "取消釘選", "column_search.cancel": "取消", - "community.column_settings.local_only": "只顯示本站", - "community.column_settings.media_only": "只顯示媒體", - "community.column_settings.remote_only": "只顯示遠端", + "community.column_settings.local_only": "僅顯示本站", + "community.column_settings.media_only": "僅顯示媒體", + "community.column_settings.remote_only": "僅顯示遠端", "compose.error.blank_post": "嘟文無法為空白。", "compose.language.change": "變更語言", "compose.language.search": "搜尋語言...", @@ -204,14 +204,14 @@ "compose.saved.body": "已儲存嘟文。", "compose_form.direct_message_warning_learn_more": "了解更多", "compose_form.encryption_warning": "Mastodon 上的嘟文並未進行端到端加密。請不要透過 Mastodon 分享任何敏感資訊。", - "compose_form.hashtag_warning": "由於這則嘟文設定為非公開,將不會列於任何主題標籤下。只有公開的嘟文才能藉由主題標籤被找到。", - "compose_form.lock_disclaimer": "您的帳號尚未 {locked}。任何人皆能跟隨您並看到您設定成只對跟隨者顯示的嘟文。", + "compose_form.hashtag_warning": "由於這則嘟文設定為「不公開」,它將不被列於任何主題標籤下。只有公開的嘟文才能藉由主題標籤被找到。", + "compose_form.lock_disclaimer": "您的帳號尚未 {locked}。任何人皆能跟隨您並看到您設定成僅有跟隨者可見的嘟文。", "compose_form.lock_disclaimer.lock": "上鎖", "compose_form.placeholder": "正在想些什麼嗎?", "compose_form.poll.duration": "投票期限", "compose_form.poll.multiple": "多選", "compose_form.poll.option_placeholder": "選項 {number}", - "compose_form.poll.single": "單一選擇", + "compose_form.poll.single": "單選", "compose_form.poll.switch_to_multiple": "變更投票為允許多個選項", "compose_form.poll.switch_to_single": "變更投票為允許單一選項", "compose_form.poll.type": "投票方式", @@ -231,7 +231,7 @@ "confirmations.delete_list.title": "是否刪除該列表?", "confirmations.discard_draft.confirm": "捨棄並繼續", "confirmations.discard_draft.edit.cancel": "恢復編輯", - "confirmations.discard_draft.edit.message": "繼續將會捨棄任何您對正在編輯的此嘟文進行之任何變更。", + "confirmations.discard_draft.edit.message": "繼續將捨棄任何您對正在編輯的此嘟文進行之任何變更。", "confirmations.discard_draft.edit.title": "是否捨棄對您的嘟文之變更?", "confirmations.discard_draft.post.cancel": "恢復草稿", "confirmations.discard_draft.post.message": "繼續將捨棄您正在撰寫中之嘟文。", @@ -259,20 +259,20 @@ "confirmations.quiet_post_quote_info.message": "當引用不於公開時間軸顯示之嘟文時,您的嘟文將自熱門時間軸隱藏。", "confirmations.quiet_post_quote_info.title": "引用不於公開時間軸顯示之嘟文", "confirmations.redraft.confirm": "刪除並重新編輯", - "confirmations.redraft.message": "您確定要刪除這則嘟文並重新編輯嗎?您將失去這則嘟文之轉嘟及最愛,且對此嘟文之回覆會變成獨立的嘟文。", + "confirmations.redraft.message": "您確定要刪除這則嘟文並重新編輯嗎?您將失去此嘟文之轉嘟及最愛,且對原嘟文之回覆將變成獨立嘟文。", "confirmations.redraft.title": "是否刪除並重新編輯該嘟文?", "confirmations.remove_from_followers.confirm": "移除跟隨者", - "confirmations.remove_from_followers.message": "{name} 將會停止跟隨您。您確定要繼續嗎?", + "confirmations.remove_from_followers.message": "{name} 將停止跟隨您。您確定要繼續嗎?", "confirmations.remove_from_followers.title": "是否移除該跟隨者?", "confirmations.revoke_quote.confirm": "移除嘟文", "confirmations.revoke_quote.message": "此動作無法復原。", - "confirmations.revoke_quote.title": "您是否確定移除嘟文?", + "confirmations.revoke_quote.title": "是否移除該嘟文?", "confirmations.unblock.confirm": "解除封鎖", - "confirmations.unblock.title": "解除封鎖 {name}?", + "confirmations.unblock.title": "是否解除封鎖 {name}?", "confirmations.unfollow.confirm": "取消跟隨", - "confirmations.unfollow.title": "取消跟隨 {name}?", + "confirmations.unfollow.title": "是否取消跟隨 {name}?", "confirmations.withdraw_request.confirm": "收回跟隨請求", - "confirmations.withdraw_request.title": "收回對 {name} 之跟隨請求?", + "confirmations.withdraw_request.title": "是否收回對 {name} 之跟隨請求?", "content_warning.hide": "隱藏嘟文", "content_warning.show": "仍要顯示", "content_warning.show_more": "顯示更多", @@ -284,7 +284,7 @@ "copypaste.copied": "已複製", "copypaste.copy_to_clipboard": "複製到剪貼簿", "directory.federated": "來自已知聯邦宇宙", - "directory.local": "僅來自 {domain} 網域", + "directory.local": "僅來自 {domain}", "directory.new_arrivals": "新人", "directory.recently_active": "最近活躍", "disabled_account_banner.account_settings": "帳號設定", @@ -298,9 +298,9 @@ "domain_block_modal.they_cant_follow": "來自此伺服器之使用者將無法跟隨您。", "domain_block_modal.they_wont_know": "他們不會知道他們已被封鎖。", "domain_block_modal.title": "是否封鎖該網域?", - "domain_block_modal.you_will_lose_num_followers": "您將會失去 {followersCount, plural, other {{followersCountDisplay} 個跟隨者}} 與 {followingCount, plural, other {{followingCountDisplay} 個您跟隨之帳號}}.", + "domain_block_modal.you_will_lose_num_followers": "您將失去 {followersCount, plural, other {{followersCountDisplay} 個跟隨者}} 與 {followingCount, plural, other {{followingCountDisplay} 個您跟隨之帳號}}。", "domain_block_modal.you_will_lose_relationships": "您將失去所有的跟隨者與您自此伺服器跟隨之帳號。", - "domain_block_modal.you_wont_see_posts": "您不會見到來自此伺服器使用者之任何嘟文或推播通知。", + "domain_block_modal.you_wont_see_posts": "您將不會見到來自此伺服器使用者之任何嘟文或推播通知。", "domain_pill.activitypub_lets_connect": "它使您能於 Mastodon 及其他不同的社群應用程式與人連結及互動。", "domain_pill.activitypub_like_language": "ActivityPub 像是 Mastodon 與其他社群網路溝通時所用的語言。", "domain_pill.server": "伺服器", @@ -567,8 +567,8 @@ "mute_modal.they_can_mention_and_follow": "他們仍可提及或跟隨您,但您不會見到他們。", "mute_modal.they_wont_know": "他們不會知道他們已被靜音。", "mute_modal.title": "是否靜音該使用者?", - "mute_modal.you_wont_see_mentions": "您不會見到提及他們的嘟文。", - "mute_modal.you_wont_see_posts": "他們仍可讀取您的嘟文,但您不會見到他們的。", + "mute_modal.you_wont_see_mentions": "您將不會見到提及他們的嘟文。", + "mute_modal.you_wont_see_posts": "他們仍可讀取您的嘟文,但您不會見到他們的嘟文。", "navigation_bar.about": "關於", "navigation_bar.account_settings": "密碼與安全性", "navigation_bar.administration": "管理介面", @@ -631,7 +631,7 @@ "notification.moderation_warning.action_disable": "您的帳號已被停用。", "notification.moderation_warning.action_mark_statuses_as_sensitive": "某些您的嘟文已被標記為敏感內容。", "notification.moderation_warning.action_none": "您的帳號已收到管理員警告。", - "notification.moderation_warning.action_sensitive": "即日起,您的嘟文將會被標記為敏感內容。", + "notification.moderation_warning.action_sensitive": "即日起,您的嘟文將被標記為敏感內容。", "notification.moderation_warning.action_silence": "您的帳號已被限制。", "notification.moderation_warning.action_suspend": "您的帳號已被停權。", "notification.own_poll": "您的投票已結束", @@ -649,10 +649,10 @@ "notification_requests.accept": "接受", "notification_requests.accept_multiple": "{count, plural, other {接受 # 則請求...}}", "notification_requests.confirm_accept_multiple.button": "{count, plural, other {接受請求}}", - "notification_requests.confirm_accept_multiple.message": "您將接受 {count, plural, other {# 則推播通知請求}}。您確定要繼續?", + "notification_requests.confirm_accept_multiple.message": "您將接受 {count, plural, other {# 則推播通知請求}}。您確定要繼續嗎?", "notification_requests.confirm_accept_multiple.title": "是否接受推播通知請求?", "notification_requests.confirm_dismiss_multiple.button": "{count, plural, other {忽略請求}}", - "notification_requests.confirm_dismiss_multiple.message": "您將忽略 {count, plural, other {# 則推播通知請求}}。您將不再能輕易存取{count, plural, other {這些}}推播通知。您確定要繼續?", + "notification_requests.confirm_dismiss_multiple.message": "您將忽略 {count, plural, other {# 則推播通知請求}}。您將不再能輕易存取{count, plural, other {這些}}推播通知。您確定要繼續嗎?", "notification_requests.confirm_dismiss_multiple.title": "是否忽略推播通知請求?", "notification_requests.dismiss": "關閉", "notification_requests.dismiss_multiple": "{count, plural, other {忽略 # 則請求...}}", @@ -759,7 +759,7 @@ "privacy.quote.anyone": "{visibility},任何人皆可引用", "privacy.quote.disabled": "{visibility},停用引用嘟文", "privacy.quote.limited": "{visibility},受限的引用嘟文", - "privacy.unlisted.additional": "此與公開嘟文完全相同,但嘟文不會出現於即時內容或主題標籤、探索、及 Mastodon 搜尋中,即使您在帳戶設定中選擇加入。", + "privacy.unlisted.additional": "此與公開嘟文完全相同,但嘟文不會出現於即時內容或主題標籤、探索、及 Mastodon 搜尋中,即使您於帳戶設定中選擇加入。", "privacy.unlisted.long": "不顯示於 Mastodon 之搜尋結果、熱門趨勢、及公開時間軸上", "privacy.unlisted.short": "不公開", "privacy_policy.last_updated": "最後更新:{date}", @@ -792,7 +792,7 @@ "reply_indicator.cancel": "取消", "reply_indicator.poll": "投票", "report.block": "封鎖", - "report.block_explanation": "您將不再看到他們的嘟文。他們將無法看到您的嘟文或是跟隨您。他們會發現他們已被封鎖。", + "report.block_explanation": "您將不再看到他們的嘟文。他們將無法檢視您的嘟文或是跟隨您。他們會發現他們已被封鎖。", "report.categories.legal": "合法性", "report.categories.other": "其他", "report.categories.spam": "垃圾訊息", @@ -806,7 +806,7 @@ "report.forward": "轉寄到 {target}", "report.forward_hint": "這個帳號屬於其他伺服器。要向該伺服器發送匿名的檢舉訊息嗎?", "report.mute": "靜音", - "report.mute_explanation": "您將不再看到他們的嘟文。他們仍能可以跟隨您以及察看您的嘟文,並且不會知道他們已被靜音。", + "report.mute_explanation": "您將不再看到他們的嘟文。他們仍能可以跟隨您以及檢視您的嘟文,並且不會知道他們已被靜音。", "report.next": "繼續", "report.placeholder": "其他備註", "report.reasons.dislike": "我不喜歡", @@ -996,7 +996,7 @@ "upload_error.limit": "已達到檔案上傳限制。", "upload_error.poll": "不允許於投票時上傳檔案。", "upload_error.quote": "引用嘟文無法上傳檔案。", - "upload_form.drag_and_drop.instructions": "請按空白鍵或 Enter 鍵取多媒體附加檔案。使用方向鍵移動多媒體附加檔案。按下空白鍵或 Enter 鍵於新位置放置多媒體附加檔案,或按下 ESC 鍵取消。", + "upload_form.drag_and_drop.instructions": "請按空白鍵或 Enter 鍵選取多媒體附加檔案。使用方向鍵移動多媒體附加檔案。按下空白鍵或 Enter 鍵於新位置放置多媒體附加檔案,或按下 ESC 鍵取消。", "upload_form.drag_and_drop.on_drag_cancel": "移動已取消。多媒體附加檔案 {item} 已被放置。", "upload_form.drag_and_drop.on_drag_end": "多媒體附加檔案 {item} 已被放置。", "upload_form.drag_and_drop.on_drag_over": "多媒體附加檔案 {item} 已被移動。", diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js index 471c7af411..166c3e3e3a 100644 --- a/app/javascript/mastodon/selectors/index.js +++ b/app/javascript/mastodon/selectors/index.js @@ -14,7 +14,7 @@ const getStatusInputSelectors = [ (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]), (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]), getFilters, - (_, { contextType }) => ['detailed', 'bookmarks', 'favourites'].includes(contextType), + (_, { contextType }) => ['detailed', 'bookmarks', 'favourites', 'search'].includes(contextType), ]; function getStatusResultFunction( diff --git a/app/javascript/mastodon/utils/links.ts b/app/javascript/mastodon/utils/links.ts new file mode 100644 index 0000000000..02b74bde4d --- /dev/null +++ b/app/javascript/mastodon/utils/links.ts @@ -0,0 +1,88 @@ +import { on } from 'delegated-events'; + +export function setupLinkListeners() { + on('click', 'a[data-confirm]', handleConfirmLink); + + // We don't want to target links with data-confirm here, as those are handled already. + on('click', 'a[data-method]:not([data-confirm])', handleMethodLink); +} + +function handleConfirmLink(event: MouseEvent) { + const anchor = event.currentTarget; + if (!(anchor instanceof HTMLAnchorElement)) { + return; + } + const message = anchor.dataset.confirm; + if (!message || !window.confirm(message)) { + event.preventDefault(); + return; + } + + if (anchor.dataset.method) { + handleMethodLink(event); + } +} + +function handleMethodLink(event: MouseEvent) { + const anchor = event.currentTarget; + if (!(anchor instanceof HTMLAnchorElement)) { + return; + } + + const method = anchor.dataset.method?.toLowerCase(); + if (!method) { + return; + } + event.preventDefault(); + + // Create and submit a form with the specified method. + const form = document.createElement('form'); + form.method = 'post'; + form.action = anchor.href; + + // Add the hidden _method input to simulate other HTTP methods. + const methodInput = document.createElement('input'); + methodInput.type = 'hidden'; + methodInput.name = '_method'; + methodInput.value = method; + form.appendChild(methodInput); + + // Add CSRF token if available for same-origin requests. + const csrf = getCSRFToken(); + if (csrf && !isCrossDomain(anchor.href)) { + const csrfInput = document.createElement('input'); + csrfInput.type = 'hidden'; + csrfInput.name = csrf.param; + csrfInput.value = csrf.token; + form.appendChild(csrfInput); + } + + // The form needs to be in the document to be submitted. + form.style.display = 'none'; + document.body.appendChild(form); + + // We use requestSubmit to ensure any form submit handlers are properly invoked. + form.requestSubmit(); +} + +function getCSRFToken() { + const param = document.querySelector( + 'meta[name="csrf-param"]', + ); + const token = document.querySelector( + 'meta[name="csrf-token"]', + ); + if (param && token) { + return { param: param.content, token: token.content }; + } + return null; +} + +function isCrossDomain(href: string) { + const link = document.createElement('a'); + link.href = href; + return ( + link.protocol !== window.location.protocol || + link.host !== window.location.host + ); +} diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 238fbe2be3..dbf67cec51 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -6204,12 +6204,14 @@ a.status-card { inset-inline-start: 0; inset-inline-end: 0; bottom: 0; - align-items: center; - justify-content: space-around; // If set to center, the fullscreen image overlay is misaligned. > div { flex-shrink: 0; overflow: auto; + display: flex; + align-items: center; + justify-content: center; + width: 100%; } } @@ -7373,6 +7375,13 @@ a.status-card { grid-template-rows: 1fr 1fr; gap: 2px; + &--layout-1 { + // The size of single images is determined by their + // aspect-ratio, applied via inline style attribute + width: initial; + max-height: 460px; + } + &--layout-2 { & > .media-gallery__item:nth-child(1) { border-end-end-radius: 0; @@ -7698,12 +7707,11 @@ a.status-card { overflow: hidden; position: relative; background: $base-shadow-color; - max-width: 100%; + max-height: 460px; border-radius: 8px; box-sizing: border-box; color: $white; display: flex; - align-items: center; outline: 1px solid var(--media-outline-color); outline-offset: -1px; z-index: 2; diff --git a/app/javascript/styles_new/mastodon/accounts.scss b/app/javascript/styles_new/mastodon/accounts.scss index dab604be2a..d1c35e3f9e 100644 --- a/app/javascript/styles_new/mastodon/accounts.scss +++ b/app/javascript/styles_new/mastodon/accounts.scss @@ -113,8 +113,8 @@ } .current { - color: var(--color-bg-inverted); - background: var(--color-text-on-inverted); + color: var(--color-bg-primary); + background: var(--color-text-primary); border-radius: 100px; cursor: default; margin: 0 10px; diff --git a/app/javascript/styles_new/mastodon/admin.scss b/app/javascript/styles_new/mastodon/admin.scss index d0cd3c5137..eaa91936b1 100644 --- a/app/javascript/styles_new/mastodon/admin.scss +++ b/app/javascript/styles_new/mastodon/admin.scss @@ -122,7 +122,11 @@ $content-width: 840px; align-items: center; gap: 6px; padding: 15px; - color: var(--color-text-secondary); + color: color-mix( + in oklab, + var(--color-text-primary), + var(--color-text-secondary) + ); text-decoration: none; transition: all 200ms linear; transition-property: color, background-color; diff --git a/app/javascript/styles_new/mastodon/components.scss b/app/javascript/styles_new/mastodon/components.scss index 970e3d128f..13d19c5b8c 100644 --- a/app/javascript/styles_new/mastodon/components.scss +++ b/app/javascript/styles_new/mastodon/components.scss @@ -4494,7 +4494,7 @@ a.status-card { z-index: 1; background: radial-gradient( ellipse, - rgb(from var(--color-bg-brand-softer-base) r g b / 23%) 0%, + rgb(from var(--color-bg-brand-base) r g b / 23%) 0%, transparent 60% ); } @@ -6085,12 +6085,14 @@ a.status-card { inset-inline-start: 0; inset-inline-end: 0; bottom: 0; - align-items: center; - justify-content: space-around; // If set to center, the fullscreen image overlay is misaligned. > div { flex-shrink: 0; overflow: auto; + display: flex; + align-items: center; + justify-content: center; + width: 100%; } } @@ -7159,6 +7161,13 @@ a.status-card { grid-template-rows: 1fr 1fr; gap: 2px; + &--layout-1 { + // The size of single images is determined by their + // aspect-ratio, applied via inline style attribute + width: initial; + max-height: 460px; + } + &--layout-2 { & > .media-gallery__item:nth-child(1) { border-end-end-radius: 0; @@ -7491,11 +7500,10 @@ a.status-card { position: relative; color: var(--color-text-on-media); background: var(--color-bg-media); - max-width: 100%; + max-height: 460px; border-radius: 8px; box-sizing: border-box; display: flex; - align-items: center; outline: 1px solid var(--color-border-media); outline-offset: -1px; z-index: 2; diff --git a/app/javascript/styles_new/mastodon/css_variables.scss b/app/javascript/styles_new/mastodon/css_variables.scss index ba3319a6b4..b270bd337f 100644 --- a/app/javascript/styles_new/mastodon/css_variables.scss +++ b/app/javascript/styles_new/mastodon/css_variables.scss @@ -79,7 +79,7 @@ // Utility --color-bg-ambient: var(--color-bg-primary); - --color-bg-elevated: var(--color-grey-800); + --color-bg-elevated: var(--color-bg-primary); --color-bg-inverted: var(--color-grey-50); --color-bg-media-base: var(--color-black); --color-bg-media-strength: 65%; @@ -87,7 +87,7 @@ var(--color-bg-media-base), var(--color-bg-media-strength) )}; - --color-bg-overlay: var(--color-bg-primary); + --color-bg-overlay: var(--color-black); --color-bg-disabled: var(--color-grey-700); // Brand diff --git a/app/lib/annual_report.rb b/app/lib/annual_report.rb index 275cc4b87d..8fab2111ed 100644 --- a/app/lib/annual_report.rb +++ b/app/lib/annual_report.rb @@ -8,14 +8,11 @@ class AnnualReport AnnualReport::TypeDistribution, AnnualReport::TopStatuses, AnnualReport::MostUsedApps, - AnnualReport::CommonlyInteractedWithAccounts, AnnualReport::TimeSeries, AnnualReport::TopHashtags, - AnnualReport::MostRebloggedAccounts, - AnnualReport::Percentiles, ].freeze - SCHEMA = 1 + SCHEMA = 2 def self.table_name_prefix 'annual_report_' @@ -26,12 +23,6 @@ class AnnualReport @year = year end - def self.prepare(year) - SOURCES.each do |klass| - klass.prepare(year) - end - end - def generate return if GeneratedAnnualReport.exists?(account: @account, year: @year) diff --git a/app/lib/annual_report/commonly_interacted_with_accounts.rb b/app/lib/annual_report/commonly_interacted_with_accounts.rb deleted file mode 100644 index 219c30063a..0000000000 --- a/app/lib/annual_report/commonly_interacted_with_accounts.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -class AnnualReport::CommonlyInteractedWithAccounts < AnnualReport::Source - MINIMUM_INTERACTIONS = 1 - SET_SIZE = 40 - - def generate - { - commonly_interacted_with_accounts: commonly_interacted_with_accounts.map do |(account_id, count)| - { - account_id: account_id.to_s, - count: count, - } - end, - } - end - - private - - def commonly_interacted_with_accounts - report_statuses.not_replying_to_account(@account).group(:in_reply_to_account_id).having(minimum_interaction_count).order(count_all: :desc).limit(SET_SIZE).count - end - - def minimum_interaction_count - Arel.star.count.gt(MINIMUM_INTERACTIONS) - end -end diff --git a/app/lib/annual_report/most_reblogged_accounts.rb b/app/lib/annual_report/most_reblogged_accounts.rb deleted file mode 100644 index df4dedb734..0000000000 --- a/app/lib/annual_report/most_reblogged_accounts.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -class AnnualReport::MostRebloggedAccounts < AnnualReport::Source - MINIMUM_REBLOGS = 1 - SET_SIZE = 10 - - def generate - { - most_reblogged_accounts: most_reblogged_accounts.map do |(account_id, count)| - { - account_id: account_id.to_s, - count: count, - } - end, - } - end - - private - - def most_reblogged_accounts - report_statuses.only_reblogs.joins(reblog: :account).group(accounts: [:id]).having(minimum_reblog_count).order(count_all: :desc).limit(SET_SIZE).count - end - - def minimum_reblog_count - Arel.star.count.gt(MINIMUM_REBLOGS) - end -end diff --git a/app/lib/annual_report/percentiles.rb b/app/lib/annual_report/percentiles.rb deleted file mode 100644 index 2b0305c415..0000000000 --- a/app/lib/annual_report/percentiles.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -class AnnualReport::Percentiles < AnnualReport::Source - def self.prepare(year) - AnnualReport::StatusesPerAccountCount.connection.exec_query(<<~SQL.squish, nil, [year, Mastodon::Snowflake.id_at(DateTime.new(year).beginning_of_year), Mastodon::Snowflake.id_at(DateTime.new(year).end_of_year)]) - INSERT INTO annual_report_statuses_per_account_counts (year, account_id, statuses_count) - SELECT $1, account_id, count(*) - FROM statuses - WHERE id BETWEEN $2 AND $3 - AND (local OR uri IS NULL) - GROUP BY account_id - ON CONFLICT (year, account_id) DO NOTHING - SQL - end - - def generate - { - percentiles: { - statuses: 100.0 - ((total_with_fewer_statuses / (total_with_any_statuses + 1.0)) * 100), - }, - } - end - - private - - def statuses_created - @statuses_created ||= report_statuses.count - end - - def total_with_fewer_statuses - @total_with_fewer_statuses ||= AnnualReport::StatusesPerAccountCount.where(year: year).where(statuses_count: ...statuses_created).count - end - - def total_with_any_statuses - @total_with_any_statuses ||= AnnualReport::StatusesPerAccountCount.where(year: year).count - end -end diff --git a/app/lib/annual_report/source.rb b/app/lib/annual_report/source.rb index 86528731f5..7f48655369 100644 --- a/app/lib/annual_report/source.rb +++ b/app/lib/annual_report/source.rb @@ -8,10 +8,6 @@ class AnnualReport::Source @year = year end - def self.prepare(_year) - # Use this method if any pre-calculations must be made before individual annual reports are generated - end - def generate raise NotImplementedError end diff --git a/app/lib/annual_report/top_hashtags.rb b/app/lib/annual_report/top_hashtags.rb index 42420a2770..a775c29bac 100644 --- a/app/lib/annual_report/top_hashtags.rb +++ b/app/lib/annual_report/top_hashtags.rb @@ -2,7 +2,7 @@ class AnnualReport::TopHashtags < AnnualReport::Source MINIMUM_TAGGINGS = 1 - SET_SIZE = 40 + SET_SIZE = 5 def generate { diff --git a/app/models/collection.rb b/app/models/collection.rb index 320933ea60..41f9ed0f02 100644 --- a/app/models/collection.rb +++ b/app/models/collection.rb @@ -27,6 +27,9 @@ class Collection < ApplicationRecord validates :name, presence: true validates :description, presence: true + validates :local, inclusion: [true, false] + validates :sensitive, inclusion: [true, false] + validates :discoverable, inclusion: [true, false] validates :uri, presence: true, if: :remote? validates :original_number_of_items, presence: true, diff --git a/app/models/session_activation.rb b/app/models/session_activation.rb index d99ecf8adb..55b1428be6 100644 --- a/app/models/session_activation.rb +++ b/app/models/session_activation.rb @@ -38,9 +38,7 @@ class SessionActivation < ApplicationRecord end def activate(**) - activation = create!(**) - purge_old - activation + create!(**).tap { purge_old } end def deactivate(id) diff --git a/app/serializers/rest/collection_serializer.rb b/app/serializers/rest/collection_serializer.rb new file mode 100644 index 0000000000..c03cc53856 --- /dev/null +++ b/app/serializers/rest/collection_serializer.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class REST::CollectionSerializer < ActiveModel::Serializer + attributes :uri, :name, :description, :local, :sensitive, :discoverable, + :created_at, :updated_at + + belongs_to :account, serializer: REST::AccountSerializer +end diff --git a/app/services/backup_service.rb b/app/services/backup_service.rb index 36baa6e5ac..91f6ea4b60 100644 --- a/app/services/backup_service.rb +++ b/app/services/backup_service.rb @@ -63,7 +63,7 @@ class BackupService < BaseService dump_actor!(zipfile) end - archive_filename = "#{['archive', Time.now.utc.strftime('%Y%m%d%H%M%S'), SecureRandom.hex(16)].join('-')}.zip" + archive_filename = "#{['archive', Time.current.to_fs(:number), SecureRandom.hex(16)].join('-')}.zip" @backup.dump = ActionDispatch::Http::UploadedFile.new(tempfile: tmp_file, filename: archive_filename) @backup.processed = true diff --git a/app/services/create_collection_service.rb b/app/services/create_collection_service.rb new file mode 100644 index 0000000000..5cf7249fee --- /dev/null +++ b/app/services/create_collection_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class CreateCollectionService + def call(params, account) + tag = params.delete(:tag) + account_ids = params.delete(:account_ids) + @collection = Collection.new(params.merge({ account:, local: true, tag: find_or_create_tag(tag) })) + build_items(account_ids) + + @collection.save! + @collection + end + + private + + def find_or_create_tag(name) + return nil if name.blank? + + Tag.find_or_create_by_names(name).first + end + + def build_items(account_ids) + return if account_ids.blank? + + account_ids.each do |account_id| + account = Account.find(account_id) + # TODO: validate preferences + @collection.collection_items.build(account:) + end + end +end diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index 516db258df..908acb5503 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -75,6 +75,7 @@ Doorkeeper.configure do :'write:accounts', :'write:blocks', :'write:bookmarks', + :'write:collections', :'write:conversations', :'write:favourites', :'write:filters', @@ -89,6 +90,7 @@ Doorkeeper.configure do :'read:accounts', :'read:blocks', :'read:bookmarks', + :'read:collections', :'read:favourites', :'read:filters', :'read:follows', diff --git a/config/locales/activerecord.lad.yml b/config/locales/activerecord.lad.yml index 8fd23b53fe..8b1fea0d96 100644 --- a/config/locales/activerecord.lad.yml +++ b/config/locales/activerecord.lad.yml @@ -30,6 +30,10 @@ lad: attributes: url: invalid: no es adreso URL valido + collection: + attributes: + tag: + unusable: no se pueden uzar doorkeeper/application: attributes: website: diff --git a/config/locales/da.yml b/config/locales/da.yml index 3cb95b3120..1a900bb849 100644 --- a/config/locales/da.yml +++ b/config/locales/da.yml @@ -1593,13 +1593,13 @@ da: invalid: Denne invitation er ikke gyldig invited_by: 'Du blev inviteret af:' max_uses: - one: 1 benyttelse - other: "%{count} benyttelser" + one: 1 anvendelse + other: "%{count} anvendelser" max_uses_prompt: Ubegrænset prompt: Generér og del links med andre for at give dem adgang til denne server table: expires_at: Udløber - uses: Benyttelser + uses: Anvendelser title: Invitér personer link_preview: author_html: Af %{name} @@ -2122,7 +2122,7 @@ da: feature_audience_title: Opbyg et publikum i tillid feature_control: Man ved selv bedst, hvad man ønsker at se på sit hjemmefeed. Ingen algoritmer eller annoncer til at spilde tiden. Følg alle på tværs af enhver Mastodon-server fra en enkelt konto og modtag deres indlæg i kronologisk rækkefølge, og gør dette hjørne af internet lidt mere personligt. feature_control_title: Hold styr på egen tidslinje - feature_creativity: Mastodon understøtter indlæg med lyd, video og billede, tilgængelighedsbeskrivelser, meningsmålinger, indhold advarsler, animerede avatarer, tilpassede emojis, miniaturebeskæringskontrol og mere, for at gøre det lettere at udtrykke sig online. Uanset om man udgiver sin kunst, musik eller podcast, så står Mastodon til rådighed. + feature_creativity: Mastodon understøtter lyd-, video- og billedindlæg, tilgængelighedsbeskrivelser, afstemninger, indholdsadvarsler, animerede avatarer, brugerdefinerede emojis, kontrol over beskæring af miniaturebilleder og meget mere, så du lettere kan udtrykke dig online. Uanset om du udgiver din kunst, din musik eller din podcast, er Mastodon der for dig. feature_creativity_title: Uovertruffen kreativitet feature_moderation: Mastodon lægger beslutningstagning tilbage i brugerens hænder. Hver server opretter deres egne regler og reguleringer, som håndhæves lokalt og ikke ovenfra som virksomhedernes sociale medier, hvilket gør det til den mest fleksible mht. at reagere på behovene hos forskellige persongrupper. Deltag på en server med de regler, man er enige med, eller driv en egen server. feature_moderation_title: Moderering af måden, tingene bør være på diff --git a/config/locales/devise.lad.yml b/config/locales/devise.lad.yml index 45e4750450..b294c1807b 100644 --- a/config/locales/devise.lad.yml +++ b/config/locales/devise.lad.yml @@ -7,6 +7,7 @@ lad: send_paranoid_instructions: Si tu adreso de posta elektronika existe en muestra baza de datos, risiviras una posta elektronika kon instruksyones sobre komo konfirmar tu adreso de posta elektronika en pokos minutos. failure: already_authenticated: Ya te konektates kon tu kuento. + closed_registrations: Tu prova de enrejistrarte tiene sido blokada por a una politika de red. Si piensas ke esto es un yerro, eskrive a %{email}. inactive: Tu kuento ainda no tyene sido aktivado. invalid: "%{authentication_keys} o kod invalido." last_attempt: Aprova una vez mas antes de ke tu kuento sea blokado. diff --git a/config/locales/lad.yml b/config/locales/lad.yml index 90b04a78f1..7747a18f5d 100644 --- a/config/locales/lad.yml +++ b/config/locales/lad.yml @@ -477,6 +477,7 @@ lad: created_at: Kriyado en delete: Efasa ip: Adreso IP + request_body: Kuerpo de la solisitud providers: active: Aktivo base_url: URL baza diff --git a/config/locales/simple_form.da.yml b/config/locales/simple_form.da.yml index b442e5add0..68f114e7fe 100644 --- a/config/locales/simple_form.da.yml +++ b/config/locales/simple_form.da.yml @@ -227,7 +227,7 @@ da: inbox_url: URL til videreformidlingsindbakken irreversible: Fjern istedet for skjul locale: Grænsefladesprog - max_uses: Maks. antal afbenyttelser + max_uses: Maks. antal anvendelser new_password: Ny adgangskode note: Biografi otp_attempt: Tofaktorkode diff --git a/config/locales/simple_form.ja.yml b/config/locales/simple_form.ja.yml index 9025e16b18..8c06f28510 100644 --- a/config/locales/simple_form.ja.yml +++ b/config/locales/simple_form.ja.yml @@ -60,6 +60,7 @@ ja: setting_display_media_default: 閲覧注意としてマークされたメディアは隠す setting_display_media_hide_all: メディアを常に隠す setting_display_media_show_all: メディアを常に表示する + setting_emoji_style: 絵文字の表示方法。「オート」の場合、可能ならネイティブの絵文字を使用し、レガシーなブラウザではTwemojiで代替します。 setting_system_scrollbars_ui: Safari/Chromeベースのデスクトップブラウザーでのみ有効です setting_use_blurhash: ぼかしはメディアの色を元に生成されますが、細部は見えにくくなっています setting_use_pending_items: 新着があってもタイムラインを自動的にスクロールしないようにします @@ -225,6 +226,7 @@ ja: setting_default_privacy: 投稿の公開範囲 setting_default_quote_policy: 引用できるユーザー setting_default_sensitive: メディアを常に閲覧注意としてマークする + setting_delete_modal: 投稿を削除する前に警告する setting_disable_hover_cards: マウスオーバーでプロフィールをポップアップしない setting_disable_swiping: スワイプでの切り替えを無効にする setting_display_media: メディアの表示 @@ -234,6 +236,7 @@ ja: setting_emoji_style: 絵文字スタイル setting_expand_spoilers: 閲覧注意としてマークされた投稿を常に展開する setting_hide_network: 繋がりを隠す + setting_missing_alt_text_modal: 代替テキストなしでメディアを投稿する前に警告する setting_quick_boosting: クイックブーストの有効化 setting_reduce_motion: アニメーションの動きを減らす setting_system_font_ui: システムのデフォルトフォントを使う diff --git a/config/locales/simple_form.lad.yml b/config/locales/simple_form.lad.yml index 35fb5fb9f4..b1d0a3e256 100644 --- a/config/locales/simple_form.lad.yml +++ b/config/locales/simple_form.lad.yml @@ -313,7 +313,9 @@ lad: domain: Domeno min_age: Edad minima user: + date_of_birth_1i: Anyo date_of_birth_2i: Mez + date_of_birth_3i: Diya role: Rolo time_zone: Zona de tiempo user_role: diff --git a/config/locales/simple_form.pt-BR.yml b/config/locales/simple_form.pt-BR.yml index a63a11220f..c0f14f7773 100644 --- a/config/locales/simple_form.pt-BR.yml +++ b/config/locales/simple_form.pt-BR.yml @@ -65,7 +65,7 @@ pt-BR: setting_display_media_hide_all: Sempre ocultar todas as mídias setting_display_media_show_all: Sempre mostrar mídia sensível setting_emoji_style: Como exibir emojis. "Automáticos" tentará usar emojis nativos, mas voltará para o Twemoji para navegadores legados. - setting_quick_boosting_html: Quando ativado, clicar no ícone de impulsionamento %{boost_icon} impulsionará imediatamente o texto, em vez de abrir o menu suspenso de impulsionamento/cotação. Move a ação de cotação para o menu %{options_icon} (Opções). + setting_quick_boosting_html: Quando ativado, clicar no ícone de impulsionamento %{boost_icon} impulsionará imediatamente o texto, em vez de abrir o menu suspenso de impulsionamento/citação. Move a ação de citação para o menu %{options_icon} (Opções). setting_system_scrollbars_ui: Se aplica apenas para navegadores de computador baseado no Safari e Chrome setting_use_blurhash: O blur é baseado nas cores da imagem oculta, ofusca a maioria dos detalhes setting_use_pending_items: Ocultar atualizações da linha do tempo atrás de um clique ao invés de rolar automaticamente @@ -237,7 +237,7 @@ pt-BR: setting_aggregate_reblogs: Agrupar boosts nas linhas setting_always_send_emails: Sempre enviar notificações por e-mail setting_auto_play_gif: Reproduzir GIFs automaticamente - setting_boost_modal: Controle que aumenta a visibilidade + setting_boost_modal: Controlar a visibilidade dos impulsos setting_default_language: Idioma dos toots setting_default_privacy: Visibilidade da publicação setting_default_quote_policy: Quem pode citar diff --git a/config/locales/simple_form.sv.yml b/config/locales/simple_form.sv.yml index 19d0fd6f9a..b7089ede18 100644 --- a/config/locales/simple_form.sv.yml +++ b/config/locales/simple_form.sv.yml @@ -232,8 +232,10 @@ sv: setting_always_send_emails: Skicka alltid e-postnotiser setting_auto_play_gif: Spela upp GIF:ar automatiskt setting_default_language: Inläggsspråk + setting_default_privacy: Inläggssynlighet setting_default_quote_policy: Vem kan citera setting_default_sensitive: Markera alltid media som känsligt + setting_delete_modal: Varna mig innan ett inlägg tas bort setting_disable_hover_cards: Inaktivera profilförhandsgranskning vid hovring setting_disable_swiping: Inaktivera svepande rörelser setting_display_media: Mediavisning @@ -243,6 +245,8 @@ sv: setting_emoji_style: Emoji-stil setting_expand_spoilers: Utöka alltid tutningar markerade med innehållsvarningar setting_hide_network: Göm ditt nätverk + setting_missing_alt_text_modal: Varna mig innan jag gör mediainlägg utan alt-text + setting_quick_boosting: Aktivera snabb-boostande setting_reduce_motion: Minska rörelser i animationer setting_system_font_ui: Använd systemets standardfont setting_system_scrollbars_ui: Använd systemets standardrullningsfält @@ -276,12 +280,17 @@ sv: content_cache_retention_period: Förvaringsperiod för fjärrinnehåll custom_css: Anpassad CSS favicon: Favicon + landing_page: Startsida för nya besökare + local_live_feed_access: Åtkomst till live-flöden med lokala inlägg + local_topic_feed_access: Åtkomst till hashtagg och länkflöden med lokala inlägg mascot: Anpassad maskot (tekniskt arv) media_cache_retention_period: Tid för bibehållande av mediecache min_age: Åldersgräns peers_api_enabled: Publicera lista över upptäckta servrar i API:et profile_directory: Aktivera profilkatalog registrations_mode: Vem kan registrera sig + remote_live_feed_access: Åtkomst till live-flöden med fjärrinlägg + remote_topic_feed_access: Åtkomst till hashtagg och länkflöden med fjärrinlägg require_invite_text: Kräv anledning för att gå med show_domain_blocks: Visa domänblockeringar show_domain_blocks_rationale: Visa varför domäner blockerades diff --git a/config/routes/api.rb b/config/routes/api.rb index e5a59682b7..47ae01a534 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -7,6 +7,8 @@ namespace :api, format: false do # Experimental JSON / REST API namespace :v1_alpha do resources :async_refreshes, only: :show + + resources :collections, only: [:create] end # JSON / REST API diff --git a/lib/mastodon/cli/email_domain_blocks.rb b/lib/mastodon/cli/email_domain_blocks.rb index 0cc9ccb705..a6093685b9 100644 --- a/lib/mastodon/cli/email_domain_blocks.rb +++ b/lib/mastodon/cli/email_domain_blocks.rb @@ -5,9 +5,33 @@ require_relative 'base' module Mastodon::CLI class EmailDomainBlocks < Base + option :only_blocked, type: :boolean, defaut: false + option :only_with_approval, type: :boolean, default: false desc 'list', 'List blocked e-mail domains' + long_desc <<-LONG_DESC + By default this command lists all domains in the email domain block list + and their associated MX records (if included). + + If the --only-blocked option is provided, this command will list only email + domains that are fully blocked from signup. + + If the --only-with-approval option is provided, this command will list only + email domains that are allowed to be used but require manual approval. + + The --only-blocked and --only-with-approval options are mutually exclusive. + LONG_DESC def list - EmailDomainBlock.parents.find_each do |parent| + fail_with_message 'Cannot specify both --only-blocked and --only-with-approval' if options[:only_blocked] && options[:only_with_approval] + + base_query = EmailDomainBlock.parents + + if options[:only_blocked] + base_query = base_query.where(allow_with_approval: false) + elsif options[:only_with_approval] + base_query = base_query.where(allow_with_approval: true) + end + + base_query.find_each do |parent| say(parent.domain.to_s, :white) shell.indent do @@ -19,6 +43,7 @@ module Mastodon::CLI end option :with_dns_records, type: :boolean + option :allow_with_approval, type: :boolean, defaut: false desc 'add DOMAIN...', 'Block e-mail domain(s)' long_desc <<-LONG_DESC Blocking an e-mail domain prevents users from signing up @@ -30,6 +55,9 @@ module Mastodon::CLI This can be helpful if you are blocking an e-mail server that has many different domains pointing to it as it allows you to essentially block it at the root. + + When the --allow-with-approval option is set, the email domains provided will + have to be manually approved for signup. LONG_DESC def add(*domains) fail_with_message 'No domain(s) given' if domains.empty? @@ -47,19 +75,18 @@ module Mastodon::CLI other_domains = [] other_domains = DomainResource.new(domain).mx if options[:with_dns_records] - email_domain_block = EmailDomainBlock.new(domain: domain, other_domains: other_domains) + email_domain_block = EmailDomainBlock.new(domain: domain, other_domains: other_domains, allow_with_approval: options[:allow_with_approval]) email_domain_block.save! processed += 1 (email_domain_block.other_domains || []).uniq.each do |hostname| - another_email_domain_block = EmailDomainBlock.new(domain: hostname, parent: email_domain_block) - if EmailDomainBlock.exists?(domain: hostname) say("#{hostname} is already blocked.", :yellow) skipped += 1 next end + another_email_domain_block = EmailDomainBlock.new(domain: hostname, parent: email_domain_block, allow_with_approval: options[:allow_with_approval]) another_email_domain_block.save! processed += 1 end diff --git a/lib/mastodon/cli/statuses.rb b/lib/mastodon/cli/statuses.rb index 7104181e97..7188bc970c 100644 --- a/lib/mastodon/cli/statuses.rb +++ b/lib/mastodon/cli/statuses.rb @@ -62,6 +62,8 @@ module Mastodon::CLI AND NOT EXISTS (SELECT 1 FROM mentions WHERE statuses.id = mentions.status_id AND mentions.account_id IN (SELECT accounts.id FROM accounts WHERE domain IS NULL)) AND NOT EXISTS (SELECT 1 FROM favourites WHERE statuses.id = favourites.status_id AND favourites.account_id IN (SELECT accounts.id FROM accounts WHERE domain IS NULL)) AND NOT EXISTS (SELECT 1 FROM bookmarks WHERE statuses.id = bookmarks.status_id AND bookmarks.account_id IN (SELECT accounts.id FROM accounts WHERE domain IS NULL)) + AND NOT EXISTS (SELECT 1 FROM quotes JOIN statuses statuses1 ON quotes.status_id = statuses1.id WHERE quotes.quoted_status_id = statuses.id AND (statuses1.uri IS NULL OR statuses1.local)) + AND NOT EXISTS (SELECT 1 FROM quotes JOIN statuses statuses1 ON quotes.quoted_status_id = statuses1.id WHERE quotes.status_id = statuses.id AND (statuses1.uri IS NULL OR statuses1.local)) #{clean_followed_sql} SQL diff --git a/package.json b/package.json index 1e26c37f75..97077b2751 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@mastodon/mastodon", "license": "AGPL-3.0-or-later", - "packageManager": "yarn@4.10.3", + "packageManager": "yarn@4.12.0", "engines": { "node": ">=20" }, @@ -48,7 +48,6 @@ "@gamestdio/websocket": "^0.3.2", "@github/webauthn-json": "^2.1.1", "@optimize-lodash/rollup-plugin": "^5.0.2", - "@rails/ujs": "7.1.600", "@react-spring/web": "^9.7.5", "@reduxjs/toolkit": "^2.0.1", "@use-gesture/react": "^10.3.1", @@ -151,7 +150,6 @@ "@types/object-assign": "^4.0.30", "@types/prop-types": "^15.7.5", "@types/punycode": "^2.1.0", - "@types/rails__ujs": "^6.0.4", "@types/react": "^18.2.7", "@types/react-dom": "^18.2.4", "@types/react-helmet": "^6.1.6", diff --git a/spec/lib/annual_report/commonly_interacted_with_accounts_spec.rb b/spec/lib/annual_report/commonly_interacted_with_accounts_spec.rb deleted file mode 100644 index 12bf3810db..0000000000 --- a/spec/lib/annual_report/commonly_interacted_with_accounts_spec.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe AnnualReport::CommonlyInteractedWithAccounts do - describe '#generate' do - subject { described_class.new(account, Time.zone.now.year) } - - context 'with an inactive account' do - let(:account) { Fabricate :account } - - it 'builds a report for an account' do - expect(subject.generate) - .to include( - commonly_interacted_with_accounts: be_an(Array).and(be_empty) - ) - end - end - - context 'with an active account' do - let(:account) { Fabricate :account } - - let(:other_account) { Fabricate :account } - let(:most_other_account) { Fabricate :account } - - before do - _other = Fabricate :status - - Fabricate :status, account: account, reply: true, in_reply_to_id: Fabricate(:status, account: other_account).id - Fabricate :status, account: account, reply: true, in_reply_to_id: Fabricate(:status, account: other_account).id - - Fabricate :status, account: account, reply: true, in_reply_to_id: Fabricate(:status, account: most_other_account).id - Fabricate :status, account: account, reply: true, in_reply_to_id: Fabricate(:status, account: most_other_account).id - Fabricate :status, account: account, reply: true, in_reply_to_id: Fabricate(:status, account: most_other_account).id - end - - it 'builds a report for an account' do - expect(subject.generate) - .to include( - commonly_interacted_with_accounts: eq( - [ - { account_id: most_other_account.id.to_s, count: 3 }, - { account_id: other_account.id.to_s, count: 2 }, - ] - ) - ) - end - end - end -end diff --git a/spec/lib/annual_report/most_reblogged_accounts_spec.rb b/spec/lib/annual_report/most_reblogged_accounts_spec.rb deleted file mode 100644 index 956549c325..0000000000 --- a/spec/lib/annual_report/most_reblogged_accounts_spec.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe AnnualReport::MostRebloggedAccounts do - describe '#generate' do - subject { described_class.new(account, Time.zone.now.year) } - - context 'with an inactive account' do - let(:account) { Fabricate :account } - - it 'builds a report for an account' do - expect(subject.generate) - .to include( - most_reblogged_accounts: be_an(Array).and(be_empty) - ) - end - end - - context 'with an active account' do - let(:account) { Fabricate :account } - - let(:other_account) { Fabricate :account } - let(:most_other_account) { Fabricate :account } - - before do - _other = Fabricate :status - Fabricate :status, account: account, reblog: Fabricate(:status, account: other_account) - Fabricate :status, account: account, reblog: Fabricate(:status, account: other_account) - - Fabricate :status, account: account, reblog: Fabricate(:status, account: most_other_account) - Fabricate :status, account: account, reblog: Fabricate(:status, account: most_other_account) - Fabricate :status, account: account, reblog: Fabricate(:status, account: most_other_account) - end - - it 'builds a report for an account' do - expect(subject.generate) - .to include( - most_reblogged_accounts: eq( - [ - { account_id: most_other_account.id.to_s, count: 3 }, - { account_id: other_account.id.to_s, count: 2 }, - ] - ) - ) - end - end - end -end diff --git a/spec/lib/annual_report/percentiles_spec.rb b/spec/lib/annual_report/percentiles_spec.rb deleted file mode 100644 index 11df81cfb6..0000000000 --- a/spec/lib/annual_report/percentiles_spec.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe AnnualReport::Percentiles do - describe '#generate' do - subject { described_class.new(account, year) } - - let(:year) { Time.zone.now.year } - - context 'with an inactive account' do - let(:account) { Fabricate :account } - - it 'builds a report for an account' do - described_class.prepare(year) - - expect(subject.generate) - .to include( - percentiles: include( - statuses: 100 - ) - ) - end - end - - context 'with an active account' do - let(:account) { Fabricate :account } - - before do - Fabricate.times 2, :status # Others as `account` - Fabricate.times 2, :status, account: account - end - - it 'builds a report for an account' do - described_class.prepare(year) - - expect(subject.generate) - .to include( - percentiles: include( - statuses: 50 - ) - ) - end - end - end -end diff --git a/spec/lib/annual_report_spec.rb b/spec/lib/annual_report_spec.rb index fa898d3ac5..bd4d0f3387 100644 --- a/spec/lib/annual_report_spec.rb +++ b/spec/lib/annual_report_spec.rb @@ -13,13 +13,4 @@ RSpec.describe AnnualReport do .to change(GeneratedAnnualReport, :count).by(1) end end - - describe '.prepare' do - before { Fabricate :status } - - it 'generates records from source class which prepare data' do - expect { described_class.prepare(Time.current.year) } - .to change(AnnualReport::StatusesPerAccountCount, :count).by(1) - end - end end diff --git a/spec/lib/mastodon/cli/email_domain_blocks_spec.rb b/spec/lib/mastodon/cli/email_domain_blocks_spec.rb index 6ce1a7c5f3..1662785f39 100644 --- a/spec/lib/mastodon/cli/email_domain_blocks_spec.rb +++ b/spec/lib/mastodon/cli/email_domain_blocks_spec.rb @@ -15,17 +15,62 @@ RSpec.describe Mastodon::CLI::EmailDomainBlocks do describe '#list' do let(:action) { :list } + context 'with both --only-blocked and --only-with-approval' do + let(:options) { { only_blocked: true, only_with_approval: true } } + + it 'warns about usage and exits' do + expect { subject } + .to raise_error(Thor::Error, 'Cannot specify both --only-blocked and --only-with-approval') + end + end + context 'with email domain block records' do let!(:parent_block) { Fabricate(:email_domain_block) } let!(:child_block) { Fabricate(:email_domain_block, parent: parent_block) } + let!(:parent_allow_block) { Fabricate(:email_domain_block, allow_with_approval: true) } + let!(:child_allow_block) { Fabricate(:email_domain_block, parent: parent_allow_block, allow_with_approval: true) } - it 'lists the blocks' do + it 'lists all the blocks by default' do expect { subject } .to output_results( parent_block.domain, - child_block.domain + child_block.domain, + parent_allow_block.domain, + child_allow_block.domain ) end + + context 'with the --only-blocked flag set' do + let(:options) { { only_blocked: true } } + + it 'lists only blocked domains' do + expect { subject } + .to output_results( + parent_block.domain, + child_block.domain + ) + .and not_output_results( + parent_allow_block.domain, + child_allow_block.domain + ) + end + end + + context 'with the --only-with-approval flag set' do + let(:options) { { only_with_approval: true } } + + it 'lists only manually approvable domains' do + expect { subject } + .to output_results( + parent_allow_block.domain, + child_allow_block.domain + ) + .and not_output_results( + parent_block.domain, + child_block.domain + ) + end + end end end @@ -56,6 +101,7 @@ RSpec.describe Mastodon::CLI::EmailDomainBlocks do context 'when no blocks exist' do let(:domain) { 'host.example' } let(:arguments) { [domain] } + let(:options) { { allow_with_approval: false } } it 'adds a new block' do expect { subject } @@ -67,7 +113,7 @@ RSpec.describe Mastodon::CLI::EmailDomainBlocks do context 'with --with-dns-records true' do let(:domain) { 'host.example' } let(:arguments) { [domain] } - let(:options) { { with_dns_records: true } } + let(:options) { { allow_with_approval: false, with_dns_records: true } } before do configure_mx(domain: domain, exchange: 'other.host') diff --git a/spec/models/collection_spec.rb b/spec/models/collection_spec.rb index c6a500210f..6c160ecb70 100644 --- a/spec/models/collection_spec.rb +++ b/spec/models/collection_spec.rb @@ -10,6 +10,12 @@ RSpec.describe Collection do it { is_expected.to validate_presence_of(:description) } + it { is_expected.to_not allow_value(nil).for(:local) } + + it { is_expected.to_not allow_value(nil).for(:sensitive) } + + it { is_expected.to_not allow_value(nil).for(:discoverable) } + context 'when collection is remote' do subject { Fabricate.build :collection, local: false } diff --git a/spec/models/session_activation_spec.rb b/spec/models/session_activation_spec.rb index 63d22f0208..036a6848cb 100644 --- a/spec/models/session_activation_spec.rb +++ b/spec/models/session_activation_spec.rb @@ -39,20 +39,24 @@ RSpec.describe SessionActivation do end describe '.activate' do - let(:options) { { user: Fabricate(:user), session_id: '1' } } + let(:user) { Fabricate :user } + let!(:session_activation) { Fabricate :session_activation, user: } - it 'calls create! and purge_old' do - allow(described_class).to receive(:create!).with(**options) - allow(described_class).to receive(:purge_old) - - described_class.activate(**options) - - expect(described_class).to have_received(:create!).with(**options) - expect(described_class).to have_received(:purge_old) + around do |example| + original = Rails.configuration.x.max_session_activations + Rails.configuration.x.max_session_activations = 1 + example.run + Rails.configuration.x.max_session_activations = original end - it 'returns an instance of SessionActivation' do - expect(described_class.activate(**options)).to be_a described_class + it 'creates a new activation and purges older ones' do + result = described_class.activate(user: user, session_id: '123') + + expect(result) + .to be_a(described_class) + .and have_attributes(session_id: '123', user:) + expect { session_activation.reload } + .to raise_error(ActiveRecord::RecordNotFound) end end diff --git a/spec/requests/api/v1_alpha/collections_spec.rb b/spec/requests/api/v1_alpha/collections_spec.rb new file mode 100644 index 0000000000..5f9c5e5f34 --- /dev/null +++ b/spec/requests/api/v1_alpha/collections_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Api::V1Alpha::Collections', feature: :collections do + include_context 'with API authentication', oauth_scopes: 'read:collections write:collections' + + describe 'POST /api/v1_alpha/collections' do + subject do + post '/api/v1_alpha/collections', headers: headers, params: params + end + + let(:params) { {} } + + it_behaves_like 'forbidden for wrong scope', 'read' + + context 'with valid params' do + let(:params) do + { + name: 'Low-traffic bots', + description: 'Really nice bots, please follow', + sensitive: '0', + discoverable: '1', + } + end + + it 'creates a collection and returns http success' do + expect do + subject + end.to change(Collection, :count).by(1) + + expect(response).to have_http_status(200) + end + end + + context 'with invalid params' do + it 'returns http unprocessable content and detailed errors' do + expect do + subject + end.to_not change(Collection, :count) + + expect(response).to have_http_status(422) + expect(response.parsed_body).to include({ + 'error' => a_hash_including({ + 'details' => a_hash_including({ + 'name' => [{ 'error' => 'ERR_BLANK', 'description' => "can't be blank" }], + 'description' => [{ 'error' => 'ERR_BLANK', 'description' => "can't be blank" }], + }), + }), + }) + end + end + end +end diff --git a/spec/serializers/rest/collection_serializer_spec.rb b/spec/serializers/rest/collection_serializer_spec.rb new file mode 100644 index 0000000000..c498937b50 --- /dev/null +++ b/spec/serializers/rest/collection_serializer_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe REST::CollectionSerializer do + subject { serialized_record_json(collection, described_class) } + + let(:collection) do + Fabricate(:collection, + name: 'Exquisite follows', + description: 'Always worth a follow', + local: true, + sensitive: true, + discoverable: false) + end + + it 'includes the relevant attributes' do + expect(subject) + .to include( + 'account' => an_instance_of(Hash), + 'name' => 'Exquisite follows', + 'description' => 'Always worth a follow', + 'local' => true, + 'sensitive' => true, + 'discoverable' => false, + 'created_at' => match_api_datetime_format, + 'updated_at' => match_api_datetime_format + ) + end +end diff --git a/spec/services/create_collection_service_spec.rb b/spec/services/create_collection_service_spec.rb new file mode 100644 index 0000000000..43842c8cc6 --- /dev/null +++ b/spec/services/create_collection_service_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe CreateCollectionService do + subject { described_class.new } + + let(:author) { Fabricate.create(:account) } + + describe '#call' do + let(:base_params) do + { + name: 'People to follow', + description: 'All my favourites', + sensitive: false, + discoverable: true, + } + end + + context 'when given valid parameters' do + it 'creates a new local collection' do + collection = nil + + expect do + collection = subject.call(base_params, author) + end.to change(Collection, :count).by(1) + + expect(collection).to be_a(Collection) + expect(collection).to be_local + end + + context 'when given account ids' do + let(:account_ids) do + Fabricate.times(2, :account).map { |a| a.id.to_s } + end + let(:params) do + base_params.merge(account_ids:) + end + + it 'also creates collection items' do + expect do + subject.call(params, author) + end.to change(CollectionItem, :count).by(2) + end + end + + context 'when given a tag' do + let(:params) { base_params.merge(tag: '#people') } + + context 'when the tag exists' do + let!(:tag) { Fabricate.create(:tag, name: 'people') } + + it 'correctly assigns the existing tag' do + collection = subject.call(params, author) + + expect(collection.tag).to eq tag + end + end + + context 'when the tag does not exist' do + it 'creates a new tag' do + collection = nil + + expect do + collection = subject.call(params, author) + end.to change(Tag, :count).by(1) + + expect(collection.tag.name).to eq 'people' + end + end + end + end + + context 'when given invalid parameters' do + it 'raises an exception' do + expect do + subject.call({}, author) + end.to raise_error(ActiveRecord::RecordInvalid) + end + end + end +end diff --git a/spec/support/command_line_helpers.rb b/spec/support/command_line_helpers.rb index 09b2b70ba1..5cd86b1019 100644 --- a/spec/support/command_line_helpers.rb +++ b/spec/support/command_line_helpers.rb @@ -7,3 +7,5 @@ module CommandLineHelpers ).to_stdout end end + +RSpec::Matchers.define_negated_matcher :not_output_results, :output_results diff --git a/streaming/package.json b/streaming/package.json index 90dd5dd0cf..0f6651b741 100644 --- a/streaming/package.json +++ b/streaming/package.json @@ -1,7 +1,7 @@ { "name": "@mastodon/streaming", "license": "AGPL-3.0-or-later", - "packageManager": "yarn@4.10.3", + "packageManager": "yarn@4.12.0", "engines": { "node": ">=20" }, diff --git a/yarn.lock b/yarn.lock index a5b4158e5c..6d0ae003ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -334,7 +334,7 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.24.4, @babel/parser@npm:^7.25.4, @babel/parser@npm:^7.27.2, @babel/parser@npm:^7.28.5": +"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.24.4, @babel/parser@npm:^7.27.2, @babel/parser@npm:^7.28.5": version: 7.28.5 resolution: "@babel/parser@npm:7.28.5" dependencies: @@ -1209,7 +1209,7 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.0.0, @babel/types@npm:^7.18.9, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.3, @babel/types@npm:^7.25.4, @babel/types@npm:^7.26.10, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.4, @babel/types@npm:^7.28.5, @babel/types@npm:^7.4.4": +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.18.9, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.3, @babel/types@npm:^7.26.10, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.4, @babel/types@npm:^7.28.5, @babel/types@npm:^7.4.4": version: 7.28.5 resolution: "@babel/types@npm:7.28.5" dependencies: @@ -1226,32 +1226,25 @@ __metadata: languageName: node linkType: hard -"@cacheable/memoize@npm:^2.0.1": - version: 2.0.1 - resolution: "@cacheable/memoize@npm:2.0.1" +"@cacheable/memory@npm:^2.0.5": + version: 2.0.5 + resolution: "@cacheable/memory@npm:2.0.5" dependencies: - "@cacheable/utils": "npm:^2.0.1" - checksum: 10c0/40ab683429132654b95edc1229c175c45045c239d647bffa75165ae572cd8da0ee9852dfb7c6baca4e8ecc0e3005c2555222ae02add8206d4a227106b1c5fc8d + "@cacheable/utils": "npm:^2.3.0" + "@keyv/bigmap": "npm:^1.1.0" + hookified: "npm:^1.12.2" + keyv: "npm:^5.5.4" + checksum: 10c0/bef5b26de58c4ca20d7cce457d053766b5fb13de48bf444e0ecf56481a16e6556a194dafc28f41906ae4e6cd053ef3d57686c770b8e7a2d381648505bd673839 languageName: node linkType: hard -"@cacheable/memory@npm:^2.0.1": - version: 2.0.1 - resolution: "@cacheable/memory@npm:2.0.1" +"@cacheable/utils@npm:^2.3.0": + version: 2.3.1 + resolution: "@cacheable/utils@npm:2.3.1" dependencies: - "@cacheable/memoize": "npm:^2.0.1" - "@cacheable/utils": "npm:^2.0.1" - "@keyv/bigmap": "npm:^1.0.0" - hookified: "npm:^1.12.0" - keyv: "npm:^5.5.1" - checksum: 10c0/dde1caf7fe66febe8dc9d32ac4eaee48b6b1e690881adfaf3c41188a00892a20514cd7847e33a64bad09c4c5d6e1377eb8e373b02b4b9fb5fa27e6a67297c625 - languageName: node - linkType: hard - -"@cacheable/utils@npm:^2.0.1": - version: 2.0.1 - resolution: "@cacheable/utils@npm:2.0.1" - checksum: 10c0/63806cca7f60add1f7bd4acee279c723760322316661a9fc6deab9ba7c03569e6cbe744d88159d4e72c1f6d296bba53b53c396d01d52294d45c9fed04c416ca4 + hashery: "npm:^1.2.0" + keyv: "npm:^5.5.4" + checksum: 10c0/04802bc11293ff569204e5f143cd11314856e3453de3e5757068cfd9df5c974a80aa9974c8400d88b22de3af70a7d8511d2d7fe927356365f41b765693a4c4bb languageName: node linkType: hard @@ -2682,12 +2675,15 @@ __metadata: languageName: node linkType: hard -"@keyv/bigmap@npm:^1.0.0": - version: 1.0.2 - resolution: "@keyv/bigmap@npm:1.0.2" +"@keyv/bigmap@npm:^1.1.0": + version: 1.3.0 + resolution: "@keyv/bigmap@npm:1.3.0" dependencies: - hookified: "npm:^1.12.1" - checksum: 10c0/1fe415265241b015c19891dc6c1909b41a5a033e57339b40f85af27355d2f52b52df01795a3f7ba37d3ec2b67e147c05914965775254ff8dbd1701adab45208a + hashery: "npm:^1.2.0" + hookified: "npm:^1.13.0" + peerDependencies: + keyv: ^5.5.4 + checksum: 10c0/68fe63451097067d8359dc25b7e5b832fe9d99493ca32602686026c8d14c9ca7ecaf19312df9420c7f54df8de1b26e7305f9189fa364a82f39730710eb895f9e languageName: node linkType: hard @@ -2712,7 +2708,6 @@ __metadata: "@gamestdio/websocket": "npm:^0.3.2" "@github/webauthn-json": "npm:^2.1.1" "@optimize-lodash/rollup-plugin": "npm:^5.0.2" - "@rails/ujs": "npm:7.1.600" "@react-spring/web": "npm:^9.7.5" "@reduxjs/toolkit": "npm:^2.0.1" "@storybook/addon-a11y": "npm:^10.0.6" @@ -2732,7 +2727,6 @@ __metadata: "@types/object-assign": "npm:^4.0.30" "@types/prop-types": "npm:^15.7.5" "@types/punycode": "npm:^2.1.0" - "@types/rails__ujs": "npm:^6.0.4" "@types/react": "npm:^18.2.7" "@types/react-dom": "npm:^18.2.4" "@types/react-helmet": "npm:^6.1.6" @@ -3211,13 +3205,6 @@ __metadata: languageName: node linkType: hard -"@rails/ujs@npm:7.1.600": - version: 7.1.600 - resolution: "@rails/ujs@npm:7.1.600" - checksum: 10c0/0ccaa68a08fbc7b084ab89a1fe49520a5cba6d99f4b0feaf0cb3d00334c59d8d798932d7e49b84aa388875d039ea1e17eb115ed96a80ad157e408a13eceef53e - languageName: node - linkType: hard - "@react-spring/animated@npm:~9.7.5": version: 9.7.5 resolution: "@react-spring/animated@npm:9.7.5" @@ -4303,13 +4290,6 @@ __metadata: languageName: node linkType: hard -"@types/rails__ujs@npm:^6.0.4": - version: 6.0.4 - resolution: "@types/rails__ujs@npm:6.0.4" - checksum: 10c0/7477cb03a0e1339b9cd5c8ac4a197a153e2ff48742b2f527c5a39dcdf80f01493011e368483290d3717662c63066fada3ab203a335804cbb3573cf575f37007e - languageName: node - linkType: hard - "@types/range-parser@npm:*": version: 1.2.7 resolution: "@types/range-parser@npm:1.2.7" @@ -4831,62 +4811,62 @@ __metadata: linkType: hard "@vitest/browser-playwright@npm:^4.0.5": - version: 4.0.7 - resolution: "@vitest/browser-playwright@npm:4.0.7" + version: 4.0.13 + resolution: "@vitest/browser-playwright@npm:4.0.13" dependencies: - "@vitest/browser": "npm:4.0.7" - "@vitest/mocker": "npm:4.0.7" + "@vitest/browser": "npm:4.0.13" + "@vitest/mocker": "npm:4.0.13" tinyrainbow: "npm:^3.0.3" peerDependencies: playwright: "*" - vitest: 4.0.7 + vitest: 4.0.13 peerDependenciesMeta: playwright: optional: false - checksum: 10c0/85969557a441c141b14bc13e3b2f1b70f7e7aa3eb8a63489b4b3af253b3fcd8019293cb56e1dea50f338eca9bc845e21ec85f1c88ab6bc3ee494fbdacac153a3 + checksum: 10c0/5a387eb02534736a25cfff442e66e8c41ef97f0db744ffe8360e484af61d66db793cb44ba8681471b8c21ba509db1775f1ba688bc7f50325eee76918773848cb languageName: node linkType: hard -"@vitest/browser@npm:4.0.7, @vitest/browser@npm:^4.0.5": - version: 4.0.7 - resolution: "@vitest/browser@npm:4.0.7" +"@vitest/browser@npm:4.0.13, @vitest/browser@npm:^4.0.5": + version: 4.0.13 + resolution: "@vitest/browser@npm:4.0.13" dependencies: - "@vitest/mocker": "npm:4.0.7" - "@vitest/utils": "npm:4.0.7" - magic-string: "npm:^0.30.19" + "@vitest/mocker": "npm:4.0.13" + "@vitest/utils": "npm:4.0.13" + magic-string: "npm:^0.30.21" pixelmatch: "npm:7.1.0" pngjs: "npm:^7.0.0" sirv: "npm:^3.0.2" tinyrainbow: "npm:^3.0.3" ws: "npm:^8.18.3" peerDependencies: - vitest: 4.0.7 - checksum: 10c0/22b6d666f7ae6220dea8d55afd787b435f4dc19656d22c7892f7caafd3c26b62fc87a091384c6044aa49b84c187581f6cad44fd8a65f1583abef5197a162b5c6 + vitest: 4.0.13 + checksum: 10c0/22c9297888a7288717cad706ca08159b3af05337a2f9b8da98fe74e683d534c8d816e40fece96f218d223a54c06762c5aa2a5db23ce8565c174ab9a70aade7f0 languageName: node linkType: hard "@vitest/coverage-v8@npm:^4.0.5": - version: 4.0.7 - resolution: "@vitest/coverage-v8@npm:4.0.7" + version: 4.0.13 + resolution: "@vitest/coverage-v8@npm:4.0.13" dependencies: "@bcoe/v8-coverage": "npm:^1.0.2" - "@vitest/utils": "npm:4.0.7" - ast-v8-to-istanbul: "npm:^0.3.5" + "@vitest/utils": "npm:4.0.13" + ast-v8-to-istanbul: "npm:^0.3.8" debug: "npm:^4.4.3" istanbul-lib-coverage: "npm:^3.2.2" istanbul-lib-report: "npm:^3.0.1" istanbul-lib-source-maps: "npm:^5.0.6" istanbul-reports: "npm:^3.2.0" - magicast: "npm:^0.3.5" - std-env: "npm:^3.9.0" + magicast: "npm:^0.5.1" + std-env: "npm:^3.10.0" tinyrainbow: "npm:^3.0.3" peerDependencies: - "@vitest/browser": 4.0.7 - vitest: 4.0.7 + "@vitest/browser": 4.0.13 + vitest: 4.0.13 peerDependenciesMeta: "@vitest/browser": optional: true - checksum: 10c0/4a34c6de4d1e8173856af078c053e5a6d4a3ad0085fd613fddbe5067b7083d6d11858788a994a3bd427630ddd56cc5eb948b59b425b2c7ba7dd73094d2f1844f + checksum: 10c0/dd462b13605fca62d20cb5a4f9d7cfda2bfa5e77aedc16ad5a633d8dabb24f68e96382ac4d16c2fdcddb45e7c4717e558f5ac51a70c64857f5e89d12d8700823 languageName: node linkType: hard @@ -4903,17 +4883,17 @@ __metadata: languageName: node linkType: hard -"@vitest/expect@npm:4.0.7": - version: 4.0.7 - resolution: "@vitest/expect@npm:4.0.7" +"@vitest/expect@npm:4.0.13": + version: 4.0.13 + resolution: "@vitest/expect@npm:4.0.13" dependencies: "@standard-schema/spec": "npm:^1.0.0" "@types/chai": "npm:^5.2.2" - "@vitest/spy": "npm:4.0.7" - "@vitest/utils": "npm:4.0.7" - chai: "npm:^6.0.1" + "@vitest/spy": "npm:4.0.13" + "@vitest/utils": "npm:4.0.13" + chai: "npm:^6.2.1" tinyrainbow: "npm:^3.0.3" - checksum: 10c0/366d7be563149b6143a6f275ae37b77ac7b13d96cd5b0992bfece5c801c1ed1cb7f4a1a1921e9fa5c47f8ad4e73bdfb9f3f362acc42be34cedcb907020c313a2 + checksum: 10c0/1cd7dc02cb650d024826f2e20260d23c2b9ab6733341045ffb59be7af73402eecd2422198d7e4eac609710730b6d11f0faf22af0c074d65445ab88d9da7f6556 languageName: node linkType: hard @@ -4936,13 +4916,13 @@ __metadata: languageName: node linkType: hard -"@vitest/mocker@npm:4.0.7": - version: 4.0.7 - resolution: "@vitest/mocker@npm:4.0.7" +"@vitest/mocker@npm:4.0.13": + version: 4.0.13 + resolution: "@vitest/mocker@npm:4.0.13" dependencies: - "@vitest/spy": "npm:4.0.7" + "@vitest/spy": "npm:4.0.13" estree-walker: "npm:^3.0.3" - magic-string: "npm:^0.30.19" + magic-string: "npm:^0.30.21" peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0-0 @@ -4951,7 +4931,7 @@ __metadata: optional: true vite: optional: true - checksum: 10c0/a500d2eca0e8b43f63358bd102e1203f3e478c0896ebe41dcdac0ab048e991736dc053bd4129dcf62ba94d4d3d2e43793175cd7deb6552cf54a2a9c8a5bab77b + checksum: 10c0/667ec4fbb77a28ede1b055b9d962beed92c2dd2d83b7bab1ed22239578a7b399180a978e26ef136301c0bc7c57c75ca178cda55ec94081856437e3b4be4a3e19 languageName: node linkType: hard @@ -4964,33 +4944,33 @@ __metadata: languageName: node linkType: hard -"@vitest/pretty-format@npm:4.0.7": - version: 4.0.7 - resolution: "@vitest/pretty-format@npm:4.0.7" +"@vitest/pretty-format@npm:4.0.13": + version: 4.0.13 + resolution: "@vitest/pretty-format@npm:4.0.13" dependencies: tinyrainbow: "npm:^3.0.3" - checksum: 10c0/4084355dbc7b1b9ee4b777adbbc44833dfd4c4a4bb2de8cb5ef28f490bf1c699eb31820157692dc87504b26e6b7bd931d0f316472895bfbc608327a671646032 + checksum: 10c0/c32ebd3457fd4b92fa89800b0ddaa2ca7de84df75be3c64f87ace006f3a3ec546a6ffd4c06f88e3161e80f9e10c83dfee61150e682eaa5a1871240d98c7ef0eb languageName: node linkType: hard -"@vitest/runner@npm:4.0.7": - version: 4.0.7 - resolution: "@vitest/runner@npm:4.0.7" +"@vitest/runner@npm:4.0.13": + version: 4.0.13 + resolution: "@vitest/runner@npm:4.0.13" dependencies: - "@vitest/utils": "npm:4.0.7" + "@vitest/utils": "npm:4.0.13" pathe: "npm:^2.0.3" - checksum: 10c0/717d7ce765eba1493051b309f82755a4b1d8594de6cd9d036864c9464dadc604c703388d96e070fe843b0216d2a7b66e59d18ad3db8055990114b3506bc172bf + checksum: 10c0/e9f95b8a413f875123e5c32322dd92bd523d6e3ba25b054f0e865f42e01f82666b847535fe5ea2ff3238faa2df16cefc7e5845d3d5ccfecb3a96ab924d31e760 languageName: node linkType: hard -"@vitest/snapshot@npm:4.0.7": - version: 4.0.7 - resolution: "@vitest/snapshot@npm:4.0.7" +"@vitest/snapshot@npm:4.0.13": + version: 4.0.13 + resolution: "@vitest/snapshot@npm:4.0.13" dependencies: - "@vitest/pretty-format": "npm:4.0.7" - magic-string: "npm:^0.30.19" + "@vitest/pretty-format": "npm:4.0.13" + magic-string: "npm:^0.30.21" pathe: "npm:^2.0.3" - checksum: 10c0/0382303038ebc58d419047fd329f848336e06b292839ea6ec05063092ce32464eb6a21c00e6487541e42b8155fa0c10b8cc2f6445ff85256b7d73feeb4a8afef + checksum: 10c0/ad3fbe9ff30bc294811556f958e0014cb03888ea06ac7c05ab41e20c582fe8e27d8f4176aaf8a8e230fc6377124af30f5622173fb459b70a30ff9dd622664be2 languageName: node linkType: hard @@ -5003,18 +4983,18 @@ __metadata: languageName: node linkType: hard -"@vitest/spy@npm:4.0.7": - version: 4.0.7 - resolution: "@vitest/spy@npm:4.0.7" - checksum: 10c0/88c8fdffa54cdfb9f4157316d1fb3308ad1630791881866878c7cbf837ad8e6e6aa79041c82635b2598ed551cf93409d34a434c87b779a30a66f55221de636fa +"@vitest/spy@npm:4.0.13": + version: 4.0.13 + resolution: "@vitest/spy@npm:4.0.13" + checksum: 10c0/64dc4c496eb9aacd3137beedccdb3265c895f8cd2626b3f76d7324ad944be5b1567ede2652eee407991796879270a63abdec4453c73185e637a1d7ff9cd1a009 languageName: node linkType: hard "@vitest/ui@npm:^4.0.5": - version: 4.0.7 - resolution: "@vitest/ui@npm:4.0.7" + version: 4.0.13 + resolution: "@vitest/ui@npm:4.0.13" dependencies: - "@vitest/utils": "npm:4.0.7" + "@vitest/utils": "npm:4.0.13" fflate: "npm:^0.8.2" flatted: "npm:^3.3.3" pathe: "npm:^2.0.3" @@ -5022,8 +5002,8 @@ __metadata: tinyglobby: "npm:^0.2.15" tinyrainbow: "npm:^3.0.3" peerDependencies: - vitest: 4.0.7 - checksum: 10c0/e7173da36e9549a8b04ee4015a64882075aa9f49a59cb135616d22c6d881244c938cf41f6ef149d7e1f42ff414dc72f2bb23f5334b4285801adf83280b23a279 + vitest: 4.0.13 + checksum: 10c0/7656762bc6a9c99850639d0809ada53ad4b842e4d9a8c7b82987b60bcf1675c98c077516a3777fce9580255538d0d050c92cb1e6f6296af6365f2387d7a972b9 languageName: node linkType: hard @@ -5038,13 +5018,13 @@ __metadata: languageName: node linkType: hard -"@vitest/utils@npm:4.0.7": - version: 4.0.7 - resolution: "@vitest/utils@npm:4.0.7" +"@vitest/utils@npm:4.0.13": + version: 4.0.13 + resolution: "@vitest/utils@npm:4.0.13" dependencies: - "@vitest/pretty-format": "npm:4.0.7" + "@vitest/pretty-format": "npm:4.0.13" tinyrainbow: "npm:^3.0.3" - checksum: 10c0/a2305c5117a30f1685f362767a0e0cc47265f3602469641f6eb01b4b708e2b1c35c33ccf480314348b21978d70a78311cce3f5bdd09de6456d528c5469093217 + checksum: 10c0/1b64872e82a652f11bfd813c0140eaae9b6e4ece39fc0e460ab2b3111b925892f1128f3b27f3a280471cfc404bb9c9289c59f8ca5387950ab35d024d154e9ec1 languageName: node linkType: hard @@ -5343,7 +5323,7 @@ __metadata: languageName: node linkType: hard -"ast-v8-to-istanbul@npm:^0.3.5": +"ast-v8-to-istanbul@npm:^0.3.8": version: 0.3.8 resolution: "ast-v8-to-istanbul@npm:0.3.8" dependencies: @@ -5700,16 +5680,16 @@ __metadata: languageName: node linkType: hard -"cacheable@npm:^2.0.1": - version: 2.0.1 - resolution: "cacheable@npm:2.0.1" +"cacheable@npm:^2.2.0": + version: 2.2.0 + resolution: "cacheable@npm:2.2.0" dependencies: - "@cacheable/memoize": "npm:^2.0.1" - "@cacheable/memory": "npm:^2.0.1" - "@cacheable/utils": "npm:^2.0.1" - hookified: "npm:^1.12.0" - keyv: "npm:^5.5.1" - checksum: 10c0/c4c16af5997850531a02b0efa150d3a06fff9560eca15a16e4042038a63487e967f00f5f1b63310523877b2ac4038e732faf0e707a5ff5d10cb861c01ed67ca8 + "@cacheable/memory": "npm:^2.0.5" + "@cacheable/utils": "npm:^2.3.0" + hookified: "npm:^1.13.0" + keyv: "npm:^5.5.4" + qified: "npm:^0.5.2" + checksum: 10c0/39b09e68b0a3da6c53dba1ed10dd13b63bf03f1fcb93ee941dd324d758e2d84466f1acb0c760fa78b23377be15ee58dc9acfb1fde85a89d2483d195b2f75e249 languageName: node linkType: hard @@ -5779,10 +5759,10 @@ __metadata: languageName: node linkType: hard -"chai@npm:^6.0.1": - version: 6.2.0 - resolution: "chai@npm:6.2.0" - checksum: 10c0/a4b7d7f5907187e09f1847afa838d6d1608adc7d822031b7900813c4ed5d9702911ac2468bf290676f22fddb3d727b1be90b57c1d0a69b902534ee29cdc6ff8a +"chai@npm:^6.2.1": + version: 6.2.1 + resolution: "chai@npm:6.2.1" + checksum: 10c0/0c2d84392d7c6d44ca5d14d94204f1760e22af68b83d1f4278b5c4d301dabfc0242da70954dd86b1eda01e438f42950de6cf9d569df2103678538e4014abe50b languageName: node linkType: hard @@ -5959,7 +5939,7 @@ __metadata: languageName: node linkType: hard -"commander@npm:^14.0.1": +"commander@npm:^14.0.2": version: 14.0.2 resolution: "commander@npm:14.0.2" checksum: 10c0/245abd1349dbad5414cb6517b7b5c584895c02c4f7836ff5395f301192b8566f9796c82d7bd6c92d07eba8775fe4df86602fca5d86d8d10bcc2aded1e21c2aeb @@ -7410,9 +7390,9 @@ __metadata: linkType: hard "fake-indexeddb@npm:^6.0.1": - version: 6.2.4 - resolution: "fake-indexeddb@npm:6.2.4" - checksum: 10c0/53b7e9e8f7e413c1a45a4f80f6deda00ef02676cc3a4457e73abff06720ec6b4f4f63fd65c1ef6b03298a1e7e4684d4bf65137a8b9ac166dcfade4b2acf229b4 + version: 6.2.5 + resolution: "fake-indexeddb@npm:6.2.5" + checksum: 10c0/6c5e2fe84a61daa06d7ad63699d1041fe61847f15f92db12415634b3db94f363a64be9e08a3c3c4434af9c3c0132086b85c4d5dc5e8e06edae1e7daf70ce1f3c languageName: node linkType: hard @@ -7513,12 +7493,12 @@ __metadata: languageName: node linkType: hard -"file-entry-cache@npm:^10.1.4": - version: 10.1.4 - resolution: "file-entry-cache@npm:10.1.4" +"file-entry-cache@npm:^11.1.0": + version: 11.1.1 + resolution: "file-entry-cache@npm:11.1.1" dependencies: - flat-cache: "npm:^6.1.13" - checksum: 10c0/78a7d6b257c620374a8fc5280f14acffc7bd5cb5d39a5bd3509c640f17209f5194eff6e3b476d19db7cfbe9f97abe85ec8d33260f7ed94225efb2a95a68841a6 + flat-cache: "npm:^6.1.19" + checksum: 10c0/aa639f5dd578f63984a941f34b112180a4bd9d091d03970752437958158932957fc576861b9fbcf4d6eceaeb0779ad5359befdc321cc1bac59aa6f56f6b1d205 languageName: node linkType: hard @@ -7590,14 +7570,14 @@ __metadata: languageName: node linkType: hard -"flat-cache@npm:^6.1.13": - version: 6.1.14 - resolution: "flat-cache@npm:6.1.14" +"flat-cache@npm:^6.1.19": + version: 6.1.19 + resolution: "flat-cache@npm:6.1.19" dependencies: - cacheable: "npm:^2.0.1" + cacheable: "npm:^2.2.0" flatted: "npm:^3.3.3" - hookified: "npm:^1.12.0" - checksum: 10c0/e17eda47414b4742bc557650788f18255068621afb66b23dfc6a47b3ef3e6c366ede7329e71406bf331ef6f4a3b243040803eb175560b4ceb204779066ba6e92 + hookified: "npm:^1.13.0" + checksum: 10c0/80c2d3c6ff2b7327920dacf2ab57e70ba4e865120209e41295689f029fcec17a6b49892ded7ce758d968d2b097fa2f9ab4e52923d971f3cdc90af9faba4680fd languageName: node linkType: hard @@ -8044,6 +8024,15 @@ __metadata: languageName: node linkType: hard +"hashery@npm:^1.2.0": + version: 1.2.0 + resolution: "hashery@npm:1.2.0" + dependencies: + hookified: "npm:^1.13.0" + checksum: 10c0/57905ae4bcb12faedf222b1f39cc05424ae2a2bba1f613b9c582a4e5012b8361c14a25a5a0c16da7eca70ee8338ad2924d6b9566667014c927a36d4b90ad5b72 + languageName: node + linkType: hard + "hasown@npm:^2.0.2": version: 2.0.2 resolution: "hasown@npm:2.0.2" @@ -8106,10 +8095,10 @@ __metadata: languageName: node linkType: hard -"hookified@npm:^1.12.0, hookified@npm:^1.12.1": - version: 1.12.1 - resolution: "hookified@npm:1.12.1" - checksum: 10c0/fe8d74ee49d1f79677dcdff7606eeb731f7a7dc59f61ec2141a11e3bb94ff6532f870649b900fa9f68568f410c504a338d8732e4d1abe61b426e645c37862e50 +"hookified@npm:^1.12.2, hookified@npm:^1.13.0": + version: 1.13.0 + resolution: "hookified@npm:1.13.0" + checksum: 10c0/26718a60385ab95f20173323c175a23b06efcc1fac613c51714c9c38038c7395ed52d3bea660840c8362d92dc38022ae4469c2a531728f6116f4df53f70505e7 languageName: node linkType: hard @@ -9073,12 +9062,12 @@ __metadata: languageName: node linkType: hard -"keyv@npm:^5.5.1": - version: 5.5.2 - resolution: "keyv@npm:5.5.2" +"keyv@npm:^5.5.4": + version: 5.5.4 + resolution: "keyv@npm:5.5.4" dependencies: "@keyv/serialize": "npm:^1.1.1" - checksum: 10c0/b0a224210e8bbc4a5913535aa7cc8552809dc81ad67311cc78a2ccfe5485b82b23b8e28ea3a1202e8352c18dd2a0b12b49cda5bb933958d2dcf88042db54c9d0 + checksum: 10c0/8dad7f61022c6348c4c691a19468b7c238198252edbd3cc08277d95253c137af7ce5ffd763b6ffded4a75cbe03dc3134f1adcd3dd26c5767c2c9c254e3b39001 languageName: node linkType: hard @@ -9202,10 +9191,10 @@ __metadata: linkType: hard "lint-staged@npm:^16.2.6": - version: 16.2.6 - resolution: "lint-staged@npm:16.2.6" + version: 16.2.7 + resolution: "lint-staged@npm:16.2.7" dependencies: - commander: "npm:^14.0.1" + commander: "npm:^14.0.2" listr2: "npm:^9.0.5" micromatch: "npm:^4.0.8" nano-spawn: "npm:^2.0.0" @@ -9214,7 +9203,7 @@ __metadata: yaml: "npm:^2.8.1" bin: lint-staged: bin/lint-staged.js - checksum: 10c0/6bae38082a0fcb3f699b144d1a4b85394f259f17a1f8a58b22122b9f1c6bb5e8340d6ee4bff12e52dbc4267377d6dde9e5c206157f381f1924a2640717f769c1 + checksum: 10c0/9a677c21a8112d823ae5bc565ba2c9e7b803786f2a021c46827a55fe44ed59def96edb24fc99c06a2545cdbbf366022ad82addcb3bf60c712f3b98ef92069717 languageName: node linkType: hard @@ -9378,7 +9367,7 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:^0.30.0, magic-string@npm:^0.30.17, magic-string@npm:^0.30.19, magic-string@npm:~0.30.11": +"magic-string@npm:^0.30.0, magic-string@npm:^0.30.17, magic-string@npm:^0.30.21, magic-string@npm:~0.30.11": version: 0.30.21 resolution: "magic-string@npm:0.30.21" dependencies: @@ -9387,14 +9376,14 @@ __metadata: languageName: node linkType: hard -"magicast@npm:^0.3.5": - version: 0.3.5 - resolution: "magicast@npm:0.3.5" +"magicast@npm:^0.5.1": + version: 0.5.1 + resolution: "magicast@npm:0.5.1" dependencies: - "@babel/parser": "npm:^7.25.4" - "@babel/types": "npm:^7.25.4" - source-map-js: "npm:^1.2.0" - checksum: 10c0/a6cacc0a848af84f03e3f5bda7b0de75e4d0aa9ddce5517fd23ed0f31b5ddd51b2d0ff0b7e09b51f7de0f4053c7a1107117edda6b0732dca3e9e39e6c5a68c64 + "@babel/parser": "npm:^7.28.5" + "@babel/types": "npm:^7.28.5" + source-map-js: "npm:^1.2.1" + checksum: 10c0/a00bbf3688b9b3e83c10b3bfe3f106cc2ccbf20c4f2dc1c9020a10556dfe0a6a6605a445ee8e86a6e2b484ec519a657b5e405532684f72678c62e4c0d32f962c languageName: node linkType: hard @@ -11156,6 +11145,15 @@ __metadata: languageName: node linkType: hard +"qified@npm:^0.5.2": + version: 0.5.2 + resolution: "qified@npm:0.5.2" + dependencies: + hookified: "npm:^1.13.0" + checksum: 10c0/4234ba1c0d3b14f31752a39f6788b2490cebad57abd1ce0cee0e04d2fe04a234463785d47559d364affe4d1578aad06fa2fd67b87044688708bf56d4a18ce44a + languageName: node + linkType: hard + "qs@npm:^6.14.0": version: 6.14.0 resolution: "qs@npm:6.14.0" @@ -12475,7 +12473,7 @@ __metadata: languageName: node linkType: hard -"source-map-js@npm:>=0.6.2 <2.0.0, source-map-js@npm:^1.0.1, source-map-js@npm:^1.0.2, source-map-js@npm:^1.2.0, source-map-js@npm:^1.2.1": +"source-map-js@npm:>=0.6.2 <2.0.0, source-map-js@npm:^1.0.1, source-map-js@npm:^1.0.2, source-map-js@npm:^1.2.1": version: 1.2.1 resolution: "source-map-js@npm:1.2.1" checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf @@ -12648,10 +12646,10 @@ __metadata: languageName: node linkType: hard -"std-env@npm:^3.9.0": - version: 3.9.0 - resolution: "std-env@npm:3.9.0" - checksum: 10c0/4a6f9218aef3f41046c3c7ecf1f98df00b30a07f4f35c6d47b28329bc2531eef820828951c7d7b39a1c5eb19ad8a46e3ddfc7deb28f0a2f3ceebee11bab7ba50 +"std-env@npm:^3.10.0": + version: 3.10.0 + resolution: "std-env@npm:3.10.0" + checksum: 10c0/1814927a45004d36dde6707eaf17552a546769bc79a6421be2c16ce77d238158dfe5de30910b78ec30d95135cc1c59ea73ee22d2ca170f8b9753f84da34c427f languageName: node linkType: hard @@ -12996,8 +12994,8 @@ __metadata: linkType: hard "stylelint@npm:^16.19.1": - version: 16.25.0 - resolution: "stylelint@npm:16.25.0" + version: 16.26.0 + resolution: "stylelint@npm:16.26.0" dependencies: "@csstools/css-parser-algorithms": "npm:^3.0.5" "@csstools/css-tokenizer": "npm:^3.0.4" @@ -13012,7 +13010,7 @@ __metadata: debug: "npm:^4.4.3" fast-glob: "npm:^3.3.3" fastest-levenshtein: "npm:^1.0.16" - file-entry-cache: "npm:^10.1.4" + file-entry-cache: "npm:^11.1.0" global-modules: "npm:^2.0.0" globby: "npm:^11.1.0" globjoin: "npm:^0.1.4" @@ -13039,7 +13037,7 @@ __metadata: write-file-atomic: "npm:^5.0.1" bin: stylelint: bin/stylelint.mjs - checksum: 10c0/80fa44ff5197419647306d9b2cf3804c10255ac30a96bb3390f2a3f1debee80e2e1cde81bb4e87d9179ababc9cc0ad6c362661835ee1d32a509b7fe44a8d3dd6 + checksum: 10c0/6f501ff051aee4fc7713635c98bf6837f889b22fe86152cfed20365ffeee0acf9d751f94ff265433b532b2a1ab7a228fc1fda3f507859acb57a689268939553d languageName: node linkType: hard @@ -14059,23 +14057,23 @@ __metadata: linkType: hard "vitest@npm:^4.0.5": - version: 4.0.7 - resolution: "vitest@npm:4.0.7" + version: 4.0.13 + resolution: "vitest@npm:4.0.13" dependencies: - "@vitest/expect": "npm:4.0.7" - "@vitest/mocker": "npm:4.0.7" - "@vitest/pretty-format": "npm:4.0.7" - "@vitest/runner": "npm:4.0.7" - "@vitest/snapshot": "npm:4.0.7" - "@vitest/spy": "npm:4.0.7" - "@vitest/utils": "npm:4.0.7" + "@vitest/expect": "npm:4.0.13" + "@vitest/mocker": "npm:4.0.13" + "@vitest/pretty-format": "npm:4.0.13" + "@vitest/runner": "npm:4.0.13" + "@vitest/snapshot": "npm:4.0.13" + "@vitest/spy": "npm:4.0.13" + "@vitest/utils": "npm:4.0.13" debug: "npm:^4.4.3" es-module-lexer: "npm:^1.7.0" expect-type: "npm:^1.2.2" - magic-string: "npm:^0.30.19" + magic-string: "npm:^0.30.21" pathe: "npm:^2.0.3" picomatch: "npm:^4.0.3" - std-env: "npm:^3.9.0" + std-env: "npm:^3.10.0" tinybench: "npm:^2.9.0" tinyexec: "npm:^0.3.2" tinyglobby: "npm:^0.2.15" @@ -14084,17 +14082,20 @@ __metadata: why-is-node-running: "npm:^2.3.0" peerDependencies: "@edge-runtime/vm": "*" + "@opentelemetry/api": ^1.9.0 "@types/debug": ^4.1.12 "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 - "@vitest/browser-playwright": 4.0.7 - "@vitest/browser-preview": 4.0.7 - "@vitest/browser-webdriverio": 4.0.7 - "@vitest/ui": 4.0.7 + "@vitest/browser-playwright": 4.0.13 + "@vitest/browser-preview": 4.0.13 + "@vitest/browser-webdriverio": 4.0.13 + "@vitest/ui": 4.0.13 happy-dom: "*" jsdom: "*" peerDependenciesMeta: "@edge-runtime/vm": optional: true + "@opentelemetry/api": + optional: true "@types/debug": optional: true "@types/node": @@ -14113,7 +14114,7 @@ __metadata: optional: true bin: vitest: vitest.mjs - checksum: 10c0/d5312e11e9ffbaf239fa5cb5d43e81a43adbdb085bf113c237eff3531805a347e6587ad1dd368a5e9639ff23f8f48650e689fb49c3cf9375d922ef2767a6416a + checksum: 10c0/8582ab1848d5d7dbbac0b3a5eae2625f44d0db887f73da2ee8f588fb13c66fe8ea26dac05c26ebb43673b735bc246764f52969f7c7e25455dfb7c6274659ae2c languageName: node linkType: hard