diff --git a/.github/workflows/bundler-audit.yml b/.github/workflows/bundler-audit.yml index 2341d6e67f..fa28d28f74 100644 --- a/.github/workflows/bundler-audit.yml +++ b/.github/workflows/bundler-audit.yml @@ -36,4 +36,4 @@ jobs: bundler-cache: true - name: Run bundler-audit - run: bundle exec bundler-audit check --update + run: bin/bundler-audit check --update diff --git a/.github/workflows/check-i18n.yml b/.github/workflows/check-i18n.yml index 7c1004329c..4f87f0fe5f 100644 --- a/.github/workflows/check-i18n.yml +++ b/.github/workflows/check-i18n.yml @@ -35,18 +35,18 @@ jobs: git diff --exit-code - name: Check locale file normalization - run: bundle exec i18n-tasks check-normalized + run: bin/i18n-tasks check-normalized - name: Check for unused strings - run: bundle exec i18n-tasks unused + run: bin/i18n-tasks unused - name: Check for missing strings in English YML run: | - bundle exec i18n-tasks add-missing -l en + bin/i18n-tasks add-missing -l en git diff --exit-code - name: Check for wrong string interpolations - run: bundle exec i18n-tasks check-consistent-interpolations + run: bin/i18n-tasks check-consistent-interpolations - name: Check that all required locale files exist - run: bundle exec rake repo:check_locales_files + run: bin/rake repo:check_locales_files diff --git a/.github/workflows/crowdin-download-stable.yml b/.github/workflows/crowdin-download-stable.yml index de21e2e58f..ef28258cca 100644 --- a/.github/workflows/crowdin-download-stable.yml +++ b/.github/workflows/crowdin-download-stable.yml @@ -46,7 +46,7 @@ jobs: uses: ./.github/actions/setup-ruby - name: Run i18n normalize task - run: bundle exec i18n-tasks normalize + run: bin/i18n-tasks normalize # Create or update the pull request - name: Create Pull Request diff --git a/.github/workflows/crowdin-download.yml b/.github/workflows/crowdin-download.yml index 900899dd52..e9b909b9e0 100644 --- a/.github/workflows/crowdin-download.yml +++ b/.github/workflows/crowdin-download.yml @@ -48,7 +48,7 @@ jobs: uses: ./.github/actions/setup-ruby - name: Run i18n normalize task - run: bundle exec i18n-tasks normalize + run: bin/i18n-tasks normalize # Create or update the pull request - name: Create Pull Request diff --git a/.github/workflows/lint-haml.yml b/.github/workflows/lint-haml.yml index a1a9e99c90..499be2010a 100644 --- a/.github/workflows/lint-haml.yml +++ b/.github/workflows/lint-haml.yml @@ -43,4 +43,4 @@ jobs: - name: Run haml-lint run: | echo "::add-matcher::.github/workflows/haml-lint-problem-matcher.json" - bundle exec haml-lint --reporter github + bin/haml-lint --reporter github diff --git a/CHANGELOG.md b/CHANGELOG.md index 0696f0b31c..240f6d1532 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,7 +68,7 @@ The following changelog entries focus on changes visible to users, administrator - `GET /api/v2/notifications`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-grouped - `GET /api/v2/notifications/:group_key`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-notification-group - `GET /api/v2/notifications/:group_key/accounts`: https://docs.joinmastodon.org/methods/grouped_notifications/#get-group-accounts - - `POST /api/v2/notifications/:group_key/dimsiss`: https://docs.joinmastodon.org/methods/grouped_notifications/#dismiss-group + - `POST /api/v2/notifications/:group_key/dismiss`: https://docs.joinmastodon.org/methods/grouped_notifications/#dismiss-group - `GET /api/v2/notifications/:unread_count`: https://docs.joinmastodon.org/methods/grouped_notifications/#unread-group-count - **Add notification policies, filtered notifications and notification requests** (#29366, #29529, #29433, #29565, #29567, #29572, #29575, #29588, #29646, #29652, #29658, #29666, #29693, #29699, #29737, #29706, #29570, #29752, #29810, #29826, #30114, #30251, #30559, #29868, #31008, #31011, #30996, #31149, #31220, #31222, #31225, #31242, #31262, #31250, #31273, #31310, #31316, #31322, #31329, #31324, #31331, #31343, #31342, #31309, #31358, #31378, #31406, #31256, #31456, #31419, #31457, #31508, #31540, #31541, #31723, #32062 and #32281 by @ClearlyClaire, @Gargron, @TheEssem, @mgmn, @oneiros, and @renchap)\ The old “Block notifications from non-followers”, “Block notifications from people you don't follow” and “Block direct messages from people you don't follow” notification settings have been replaced by a new set of settings found directly in the notification column.\ diff --git a/Dockerfile b/Dockerfile index c91f10de0f..4d6287912e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# syntax=docker/dockerfile:1.10 +# syntax=docker/dockerfile:1.11 # This file is designed for production server deployment, not local development work # For a containerized local dev environment, see: https://github.com/mastodon/mastodon/blob/main/README.md#docker diff --git a/Gemfile.lock b/Gemfile.lock index 471795a7e7..b4dd15989e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -93,10 +93,9 @@ GEM annotaterb (4.13.0) ast (2.4.2) attr_required (1.0.2) - awrence (1.2.1) aws-eventstream (1.3.0) - aws-partitions (1.1012.0) - aws-sdk-core (3.213.0) + aws-partitions (1.1013.0) + aws-sdk-core (3.214.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -104,7 +103,7 @@ GEM aws-sdk-kms (1.96.0) aws-sdk-core (~> 3, >= 3.210.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.173.0) + aws-sdk-s3 (1.174.0) aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) @@ -349,7 +348,8 @@ GEM json-schema (5.1.0) addressable (~> 2.8) jsonapi-renderer (0.2.2) - jwt (2.7.1) + jwt (2.9.3) + base64 kaminari (1.2.2) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.2) @@ -411,7 +411,7 @@ GEM minitest (5.25.1) msgpack (1.7.5) multi_json (1.15.0) - mutex_m (0.2.0) + mutex_m (0.3.0) net-http (0.5.0) uri net-imap (0.5.1) @@ -424,7 +424,7 @@ GEM timeout net-smtp (0.5.0) net-protocol - nio4r (2.7.3) + nio4r (2.7.4) nokogiri (1.16.7) mini_portile2 (~> 2.8.2) racc (~> 1.4) @@ -472,7 +472,7 @@ GEM opentelemetry-common (~> 0.20) opentelemetry-sdk (~> 1.2) opentelemetry-semantic_conventions - opentelemetry-helpers-sql-obfuscation (0.2.0) + opentelemetry-helpers-sql-obfuscation (0.2.1) opentelemetry-common (~> 0.21) opentelemetry-instrumentation-action_mailer (0.2.0) opentelemetry-api (~> 1.0) @@ -492,7 +492,7 @@ GEM opentelemetry-instrumentation-active_model_serializers (0.20.2) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-active_record (0.8.0) + opentelemetry-instrumentation-active_record (0.8.1) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-active_support (0.6.0) @@ -505,29 +505,29 @@ GEM opentelemetry-instrumentation-concurrent_ruby (0.21.4) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-excon (0.22.4) + opentelemetry-instrumentation-excon (0.22.5) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-faraday (0.24.6) + opentelemetry-instrumentation-faraday (0.24.7) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-http (0.23.4) + opentelemetry-instrumentation-http (0.23.5) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-http_client (0.22.7) + opentelemetry-instrumentation-http_client (0.22.8) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-net_http (0.22.7) + opentelemetry-instrumentation-net_http (0.22.8) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-pg (0.29.0) + opentelemetry-instrumentation-pg (0.29.1) opentelemetry-api (~> 1.0) opentelemetry-helpers-sql-obfuscation opentelemetry-instrumentation-base (~> 0.22.1) opentelemetry-instrumentation-rack (0.25.0) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-base (~> 0.22.1) - opentelemetry-instrumentation-rails (0.33.0) + opentelemetry-instrumentation-rails (0.33.1) opentelemetry-api (~> 1.0) opentelemetry-instrumentation-action_mailer (~> 0.2.0) opentelemetry-instrumentation-action_pack (~> 0.10.0) @@ -580,7 +580,7 @@ GEM psych (5.2.0) stringio public_suffix (6.0.1) - puma (6.4.3) + puma (6.5.0) nio4r (~> 2.0) pundit (2.4.0) activesupport (>= 3.0.0) @@ -752,7 +752,7 @@ GEM activerecord (>= 4.0.0) railties (>= 4.0.0) securerandom (0.3.2) - selenium-webdriver (4.26.0) + selenium-webdriver (4.27.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) @@ -844,9 +844,8 @@ GEM public_suffix warden (1.2.9) rack (>= 2.0.9) - webauthn (3.1.0) + webauthn (3.2.2) android_key_attestation (~> 0.3.0) - awrence (~> 1.1) bindata (~> 2.4) cbor (~> 0.5.9) cose (~> 1.1) @@ -1031,7 +1030,7 @@ DEPENDENCIES xorcist (~> 1.1) RUBY VERSION - ruby 3.3.5p100 + ruby 3.3.6p108 BUNDLED WITH - 2.5.22 + 2.5.23 diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d493bd43bf..7a858ed059 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -22,7 +22,6 @@ class ApplicationController < ActionController::Base helper_method :use_seamless_external_login? helper_method :sso_account_settings helper_method :limited_federation_mode? - helper_method :body_class_string helper_method :skip_csrf_meta_tags? rescue_from ActionController::ParameterMissing, Paperclip::AdapterRegistry::NoHandlerError, with: :bad_request @@ -158,10 +157,6 @@ class ApplicationController < ActionController::Base current_user.setting_theme end - def body_class_string - @body_classes || '' - end - def respond_with_error(code) respond_to do |format| format.any { render "errors/#{code}", layout: 'error', status: code, formats: [:html] } diff --git a/app/controllers/concerns/cache_concern.rb b/app/controllers/concerns/cache_concern.rb index 1823b5b8ed..b1b09f2aab 100644 --- a/app/controllers/concerns/cache_concern.rb +++ b/app/controllers/concerns/cache_concern.rb @@ -28,7 +28,7 @@ module CacheConcern def render_with_cache(**options) raise ArgumentError, 'Only JSON render calls are supported' unless options.key?(:json) || block_given? - key = options.delete(:key) || [[params[:controller], params[:action]].join('/'), options[:json].respond_to?(:cache_key) ? options[:json].cache_key : nil, options[:fields].nil? ? nil : options[:fields].join(',')].compact.join(':') + key = options.delete(:key) || [[params[:controller], params[:action]].join('/'), options[:json].respond_to?(:cache_key) ? options[:json].cache_key : nil, options[:fields]&.join(',')].compact.join(':') expires_in = options.delete(:expires_in) || 3.minutes body = Rails.cache.read(key, raw: true) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 3d5025724f..9861ee7e8e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -143,7 +143,7 @@ module ApplicationHelper end def body_classes - output = body_class_string.split + output = [] output << content_for(:body_classes) output << "theme-#{current_theme.parameterize}" output << 'system-font' if current_account&.user&.setting_system_font_ui diff --git a/app/javascript/entrypoints/public.tsx b/app/javascript/entrypoints/public.tsx index c1e8418014..9e8ff9caa1 100644 --- a/app/javascript/entrypoints/public.tsx +++ b/app/javascript/entrypoints/public.tsx @@ -230,62 +230,6 @@ function loaded() { } }, ); - - Rails.delegate( - document, - 'button.status__content__spoiler-link', - 'click', - function () { - if (!(this instanceof HTMLButtonElement)) return; - - const statusEl = this.parentNode?.parentNode; - - if ( - !( - statusEl instanceof HTMLDivElement && - statusEl.classList.contains('.status__content') - ) - ) - return; - - if (statusEl.dataset.spoiler === 'expanded') { - statusEl.dataset.spoiler = 'folded'; - this.textContent = new IntlMessageFormat( - localeData['status.show_more'] ?? 'Show more', - locale, - ).format() as string; - } else { - statusEl.dataset.spoiler = 'expanded'; - this.textContent = new IntlMessageFormat( - localeData['status.show_less'] ?? 'Show less', - locale, - ).format() as string; - } - }, - ); - - document - .querySelectorAll('button.status__content__spoiler-link') - .forEach((spoilerLink) => { - const statusEl = spoilerLink.parentNode?.parentNode; - - if ( - !( - statusEl instanceof HTMLDivElement && - statusEl.classList.contains('.status__content') - ) - ) - return; - - const message = - statusEl.dataset.spoiler === 'expanded' - ? (localeData['status.show_less'] ?? 'Show less') - : (localeData['status.show_more'] ?? 'Show more'); - spoilerLink.textContent = new IntlMessageFormat( - message, - locale, - ).format() as string; - }); } Rails.delegate( @@ -439,6 +383,24 @@ Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => { }); }); +Rails.delegate(document, '.rules-list button', 'click', ({ target }) => { + if (!(target instanceof HTMLElement)) { + return; + } + + const button = target.closest('button'); + + if (!button) { + return; + } + + if (button.ariaExpanded === 'true') { + button.ariaExpanded = 'false'; + } else { + button.ariaExpanded = 'true'; + } +}); + function main() { ready(loaded).catch((error: unknown) => { console.error(error); diff --git a/app/javascript/mastodon/actions/suggestions.js b/app/javascript/mastodon/actions/suggestions.js deleted file mode 100644 index 258ffa901d..0000000000 --- a/app/javascript/mastodon/actions/suggestions.js +++ /dev/null @@ -1,58 +0,0 @@ -import api from '../api'; - -import { fetchRelationships } from './accounts'; -import { importFetchedAccounts } from './importer'; - -export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST'; -export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS'; -export const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL'; - -export const SUGGESTIONS_DISMISS = 'SUGGESTIONS_DISMISS'; - -export function fetchSuggestions(withRelationships = false) { - return (dispatch) => { - dispatch(fetchSuggestionsRequest()); - - api().get('/api/v2/suggestions', { params: { limit: 20 } }).then(response => { - dispatch(importFetchedAccounts(response.data.map(x => x.account))); - dispatch(fetchSuggestionsSuccess(response.data)); - - if (withRelationships) { - dispatch(fetchRelationships(response.data.map(item => item.account.id))); - } - }).catch(error => dispatch(fetchSuggestionsFail(error))); - }; -} - -export function fetchSuggestionsRequest() { - return { - type: SUGGESTIONS_FETCH_REQUEST, - skipLoading: true, - }; -} - -export function fetchSuggestionsSuccess(suggestions) { - return { - type: SUGGESTIONS_FETCH_SUCCESS, - suggestions, - skipLoading: true, - }; -} - -export function fetchSuggestionsFail(error) { - return { - type: SUGGESTIONS_FETCH_FAIL, - error, - skipLoading: true, - skipAlert: true, - }; -} - -export const dismissSuggestion = accountId => (dispatch) => { - dispatch({ - type: SUGGESTIONS_DISMISS, - id: accountId, - }); - - api().delete(`/api/v1/suggestions/${accountId}`).catch(() => {}); -}; diff --git a/app/javascript/mastodon/actions/suggestions.ts b/app/javascript/mastodon/actions/suggestions.ts new file mode 100644 index 0000000000..0eadfa6b47 --- /dev/null +++ b/app/javascript/mastodon/actions/suggestions.ts @@ -0,0 +1,24 @@ +import { + apiGetSuggestions, + apiDeleteSuggestion, +} from 'mastodon/api/suggestions'; +import { createDataLoadingThunk } from 'mastodon/store/typed_functions'; + +import { fetchRelationships } from './accounts'; +import { importFetchedAccounts } from './importer'; + +export const fetchSuggestions = createDataLoadingThunk( + 'suggestions/fetch', + () => apiGetSuggestions(20), + (data, { dispatch }) => { + dispatch(importFetchedAccounts(data.map((x) => x.account))); + dispatch(fetchRelationships(data.map((x) => x.account.id))); + + return data; + }, +); + +export const dismissSuggestion = createDataLoadingThunk( + 'suggestions/dismiss', + ({ accountId }: { accountId: string }) => apiDeleteSuggestion(accountId), +); diff --git a/app/javascript/mastodon/api/suggestions.ts b/app/javascript/mastodon/api/suggestions.ts new file mode 100644 index 0000000000..d4817698cc --- /dev/null +++ b/app/javascript/mastodon/api/suggestions.ts @@ -0,0 +1,8 @@ +import { apiRequestGet, apiRequestDelete } from 'mastodon/api'; +import type { ApiSuggestionJSON } from 'mastodon/api_types/suggestions'; + +export const apiGetSuggestions = (limit: number) => + apiRequestGet('v2/suggestions', { limit }); + +export const apiDeleteSuggestion = (accountId: string) => + apiRequestDelete(`v1/suggestions/${accountId}`); diff --git a/app/javascript/mastodon/api_types/suggestions.ts b/app/javascript/mastodon/api_types/suggestions.ts new file mode 100644 index 0000000000..7d91daf901 --- /dev/null +++ b/app/javascript/mastodon/api_types/suggestions.ts @@ -0,0 +1,13 @@ +import type { ApiAccountJSON } from 'mastodon/api_types/accounts'; + +export type ApiSuggestionSourceJSON = + | 'featured' + | 'most_followed' + | 'most_interactions' + | 'similar_to_recently_followed' + | 'friends_of_friends'; + +export interface ApiSuggestionJSON { + sources: [ApiSuggestionSourceJSON, ...ApiSuggestionSourceJSON[]]; + account: ApiAccountJSON; +} diff --git a/app/javascript/mastodon/components/account.jsx b/app/javascript/mastodon/components/account.jsx index 265c68697b..fa66fd56bb 100644 --- a/app/javascript/mastodon/components/account.jsx +++ b/app/javascript/mastodon/components/account.jsx @@ -10,6 +10,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import { EmptyAccount } from 'mastodon/components/empty_account'; +import { FollowButton } from 'mastodon/components/follow_button'; import { ShortNumber } from 'mastodon/components/short_number'; import { VerifiedBadge } from 'mastodon/components/verified_badge'; @@ -23,9 +24,6 @@ import { DisplayName } from './display_name'; import { RelativeTimestamp } from './relative_timestamp'; const messages = defineMessages({ - follow: { id: 'account.follow', defaultMessage: 'Follow' }, - unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, - cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' }, unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' }, unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' }, mute_notifications: { id: 'account.mute_notifications_short', defaultMessage: 'Mute notifications' }, @@ -35,13 +33,9 @@ const messages = defineMessages({ more: { id: 'status.more', defaultMessage: 'More' }, }); -const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifications, hidden, minimal, defaultAction, withBio }) => { +const Account = ({ size = 46, account, onBlock, onMute, onMuteNotifications, hidden, minimal, defaultAction, withBio }) => { const intl = useIntl(); - const handleFollow = useCallback(() => { - onFollow(account); - }, [onFollow, account]); - const handleBlock = useCallback(() => { onBlock(account); }, [onBlock, account]); @@ -74,13 +68,12 @@ const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifica let buttons; if (account.get('id') !== me && account.get('relationship', null) !== null) { - const following = account.getIn(['relationship', 'following']); const requested = account.getIn(['relationship', 'requested']); const blocking = account.getIn(['relationship', 'blocking']); const muting = account.getIn(['relationship', 'muting']); if (requested) { - buttons = + )} + + ); +}; diff --git a/app/javascript/mastodon/components/follow_button.tsx b/app/javascript/mastodon/components/follow_button.tsx index 46314af309..faf9d8bdb8 100644 --- a/app/javascript/mastodon/components/follow_button.tsx +++ b/app/javascript/mastodon/components/follow_button.tsx @@ -99,7 +99,12 @@ export const FollowButton: React.FC<{ return ( - - - - -
-
- {suggestions.map(suggestion => ( - - ))} -
- - {canScrollLeft && ( - - )} - - {canScrollRight && ( - - )} -
- - ); -}; - -InlineFollowSuggestions.propTypes = { - hidden: PropTypes.bool, -}; diff --git a/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.tsx b/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.tsx new file mode 100644 index 0000000000..5fd47443d9 --- /dev/null +++ b/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.tsx @@ -0,0 +1,326 @@ +import { useEffect, useCallback, useRef, useState } from 'react'; + +import { FormattedMessage, useIntl, defineMessages } from 'react-intl'; + +import { Link } from 'react-router-dom'; + +import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react'; +import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; +import CloseIcon from '@/material-icons/400-24px/close.svg?react'; +import InfoIcon from '@/material-icons/400-24px/info.svg?react'; +import { changeSetting } from 'mastodon/actions/settings'; +import { + fetchSuggestions, + dismissSuggestion, +} from 'mastodon/actions/suggestions'; +import type { ApiSuggestionSourceJSON } from 'mastodon/api_types/suggestions'; +import { Avatar } from 'mastodon/components/avatar'; +import { DisplayName } from 'mastodon/components/display_name'; +import { FollowButton } from 'mastodon/components/follow_button'; +import { Icon } from 'mastodon/components/icon'; +import { IconButton } from 'mastodon/components/icon_button'; +import { VerifiedBadge } from 'mastodon/components/verified_badge'; +import { domain } from 'mastodon/initial_state'; +import { useAppDispatch, useAppSelector } from 'mastodon/store'; + +const messages = defineMessages({ + follow: { id: 'account.follow', defaultMessage: 'Follow' }, + unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + previous: { id: 'lightbox.previous', defaultMessage: 'Previous' }, + next: { id: 'lightbox.next', defaultMessage: 'Next' }, + dismiss: { + id: 'follow_suggestions.dismiss', + defaultMessage: "Don't show again", + }, + friendsOfFriendsHint: { + id: 'follow_suggestions.hints.friends_of_friends', + defaultMessage: 'This profile is popular among the people you follow.', + }, + similarToRecentlyFollowedHint: { + id: 'follow_suggestions.hints.similar_to_recently_followed', + defaultMessage: + 'This profile is similar to the profiles you have most recently followed.', + }, + featuredHint: { + id: 'follow_suggestions.hints.featured', + defaultMessage: 'This profile has been hand-picked by the {domain} team.', + }, + mostFollowedHint: { + id: 'follow_suggestions.hints.most_followed', + defaultMessage: 'This profile is one of the most followed on {domain}.', + }, + mostInteractionsHint: { + id: 'follow_suggestions.hints.most_interactions', + defaultMessage: + 'This profile has been recently getting a lot of attention on {domain}.', + }, +}); + +const Source: React.FC<{ + id: ApiSuggestionSourceJSON; +}> = ({ id }) => { + const intl = useIntl(); + + let label, hint; + + switch (id) { + case 'friends_of_friends': + hint = intl.formatMessage(messages.friendsOfFriendsHint); + label = ( + + ); + break; + case 'similar_to_recently_followed': + hint = intl.formatMessage(messages.similarToRecentlyFollowedHint); + label = ( + + ); + break; + case 'featured': + hint = intl.formatMessage(messages.featuredHint, { domain }); + label = ( + + ); + break; + case 'most_followed': + hint = intl.formatMessage(messages.mostFollowedHint, { domain }); + label = ( + + ); + break; + case 'most_interactions': + hint = intl.formatMessage(messages.mostInteractionsHint, { domain }); + label = ( + + ); + break; + } + + return ( +
+ + {label} +
+ ); +}; + +const Card: React.FC<{ + id: string; + sources: [ApiSuggestionSourceJSON, ...ApiSuggestionSourceJSON[]]; +}> = ({ id, sources }) => { + const intl = useIntl(); + const account = useAppSelector((state) => state.accounts.get(id)); + const firstVerifiedField = account?.fields.find((item) => !!item.verified_at); + const dispatch = useAppDispatch(); + + const handleDismiss = useCallback(() => { + void dispatch(dismissSuggestion({ accountId: id })); + }, [id, dispatch]); + + return ( +
+ + +
+ + + +
+ +
+ + + + {firstVerifiedField ? ( + + ) : ( + + )} +
+ + +
+ ); +}; + +const DISMISSIBLE_ID = 'home/follow-suggestions'; + +export const InlineFollowSuggestions: React.FC<{ + hidden?: boolean; +}> = ({ hidden }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const suggestions = useAppSelector((state) => state.suggestions.items); + const isLoading = useAppSelector((state) => state.suggestions.isLoading); + const dismissed = useAppSelector( + (state) => + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + state.settings.getIn(['dismissed_banners', DISMISSIBLE_ID]) as boolean, + ); + const bodyRef = useRef(null); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(true); + + useEffect(() => { + void dispatch(fetchSuggestions()); + }, [dispatch]); + + useEffect(() => { + if (!bodyRef.current) { + return; + } + + if (getComputedStyle(bodyRef.current).direction === 'rtl') { + setCanScrollLeft( + bodyRef.current.clientWidth - bodyRef.current.scrollLeft < + bodyRef.current.scrollWidth, + ); + setCanScrollRight(bodyRef.current.scrollLeft < 0); + } else { + setCanScrollLeft(bodyRef.current.scrollLeft > 0); + setCanScrollRight( + bodyRef.current.scrollLeft + bodyRef.current.clientWidth < + bodyRef.current.scrollWidth, + ); + } + }, [setCanScrollRight, setCanScrollLeft, suggestions]); + + const handleLeftNav = useCallback(() => { + if (!bodyRef.current) { + return; + } + + bodyRef.current.scrollLeft -= 200; + }, []); + + const handleRightNav = useCallback(() => { + if (!bodyRef.current) { + return; + } + + bodyRef.current.scrollLeft += 200; + }, []); + + const handleScroll = useCallback(() => { + if (!bodyRef.current) { + return; + } + + if (getComputedStyle(bodyRef.current).direction === 'rtl') { + setCanScrollLeft( + bodyRef.current.clientWidth - bodyRef.current.scrollLeft < + bodyRef.current.scrollWidth, + ); + setCanScrollRight(bodyRef.current.scrollLeft < 0); + } else { + setCanScrollLeft(bodyRef.current.scrollLeft > 0); + setCanScrollRight( + bodyRef.current.scrollLeft + bodyRef.current.clientWidth < + bodyRef.current.scrollWidth, + ); + } + }, [setCanScrollRight, setCanScrollLeft]); + + const handleDismiss = useCallback(() => { + dispatch(changeSetting(['dismissed_banners', DISMISSIBLE_ID], true)); + }, [dispatch]); + + if (dismissed || (!isLoading && suggestions.length === 0)) { + return null; + } + + if (hidden) { + return
; + } + + return ( +
+
+

+ +

+ +
+ + + + +
+
+ +
+
+ {suggestions.map((suggestion) => ( + + ))} +
+ + {canScrollLeft && ( + + )} + + {canScrollRight && ( + + )} +
+
+ ); +}; diff --git a/app/javascript/mastodon/features/lists/members.tsx b/app/javascript/mastodon/features/lists/members.tsx index bdd5064ce4..184b54b92d 100644 --- a/app/javascript/mastodon/features/lists/members.tsx +++ b/app/javascript/mastodon/features/lists/members.tsx @@ -7,11 +7,8 @@ import { useParams, Link } from 'react-router-dom'; import { useDebouncedCallback } from 'use-debounce'; -import AddIcon from '@/material-icons/400-24px/add.svg?react'; -import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react'; import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react'; -import { fetchFollowing } from 'mastodon/actions/accounts'; import { importFetchedAccounts } from 'mastodon/actions/importer'; import { fetchList } from 'mastodon/actions/lists'; import { apiRequest } from 'mastodon/api'; @@ -25,14 +22,12 @@ import { Avatar } from 'mastodon/components/avatar'; import { Button } from 'mastodon/components/button'; import Column from 'mastodon/components/column'; import { ColumnHeader } from 'mastodon/components/column_header'; +import { ColumnSearchHeader } from 'mastodon/components/column_search_header'; import { FollowersCounter } from 'mastodon/components/counters'; import { DisplayName } from 'mastodon/components/display_name'; -import { Icon } from 'mastodon/components/icon'; import ScrollableList from 'mastodon/components/scrollable_list'; import { ShortNumber } from 'mastodon/components/short_number'; import { VerifiedBadge } from 'mastodon/components/verified_badge'; -import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context'; -import { me } from 'mastodon/initial_state'; import { useAppDispatch, useAppSelector } from 'mastodon/store'; const messages = defineMessages({ @@ -49,54 +44,6 @@ const messages = defineMessages({ type Mode = 'remove' | 'add'; -const ColumnSearchHeader: React.FC<{ - onBack: () => void; - onSubmit: (value: string) => void; -}> = ({ onBack, onSubmit }) => { - const intl = useIntl(); - const [value, setValue] = useState(''); - - const handleChange = useCallback( - ({ target: { value } }: React.ChangeEvent) => { - setValue(value); - onSubmit(value); - }, - [setValue, onSubmit], - ); - - const handleSubmit = useCallback(() => { - onSubmit(value); - }, [onSubmit, value]); - - return ( - -
- - - -
-
- ); -}; - const AccountItem: React.FC<{ accountId: string; listId: string; @@ -156,6 +103,7 @@ const AccountItem: React.FC<{ text={intl.formatMessage( partOfList ? messages.remove : messages.add, )} + secondary={partOfList} onClick={handleClick} />
@@ -171,9 +119,6 @@ const ListMembers: React.FC<{ const { id } = useParams<{ id: string }>(); const intl = useIntl(); - const followingAccountIds = useAppSelector( - (state) => state.user_lists.getIn(['following', me, 'items']) as string[], - ); const [searching, setSearching] = useState(false); const [accountIds, setAccountIds] = useState([]); const [searchAccountIds, setSearchAccountIds] = useState([]); @@ -195,8 +140,6 @@ const ListMembers: React.FC<{ .catch(() => { setLoading(false); }); - - dispatch(fetchFollowing(me)); } }, [dispatch, id]); @@ -265,8 +208,8 @@ const ListMembers: React.FC<{ let displayedAccountIds: string[]; - if (mode === 'add') { - displayedAccountIds = searching ? searchAccountIds : followingAccountIds; + if (mode === 'add' && searching) { + displayedAccountIds = searchAccountIds; } else { displayedAccountIds = accountIds; } @@ -276,31 +219,21 @@ const ListMembers: React.FC<{ bindToDocument={!multiColumn} label={intl.formatMessage(messages.heading)} > - {mode === 'remove' ? ( - - - - } - /> - ) : ( - - )} + + + -
+ <> + {displayedAccountIds.length > 0 &&
} -
- - - -
- - ) +
+ + + +
+ } emptyMessage={ mode === 'remove' ? ( diff --git a/app/javascript/mastodon/features/notifications/components/filter_bar.jsx b/app/javascript/mastodon/features/notifications/components/filter_bar.jsx deleted file mode 100644 index c288c2c0de..0000000000 --- a/app/javascript/mastodon/features/notifications/components/filter_bar.jsx +++ /dev/null @@ -1,119 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; - -import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react'; -import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react'; -import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react'; -import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; -import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react'; -import StarIcon from '@/material-icons/400-24px/star.svg?react'; -import { Icon } from 'mastodon/components/icon'; - -const tooltips = defineMessages({ - mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' }, - favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favorites' }, - boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' }, - polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' }, - follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' }, - statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' }, -}); - -class FilterBar extends PureComponent { - - static propTypes = { - selectFilter: PropTypes.func.isRequired, - selectedFilter: PropTypes.string.isRequired, - advancedMode: PropTypes.bool.isRequired, - intl: PropTypes.object.isRequired, - }; - - onClick (notificationType) { - return () => this.props.selectFilter(notificationType); - } - - render () { - const { selectedFilter, advancedMode, intl } = this.props; - const renderedElement = !advancedMode ? ( -
- - -
- ) : ( -
- - - - - - - -
- ); - return renderedElement; - } - -} - -export default injectIntl(FilterBar); diff --git a/app/javascript/mastodon/features/notifications/containers/filter_bar_container.js b/app/javascript/mastodon/features/notifications/containers/filter_bar_container.js deleted file mode 100644 index 4e0184cef3..0000000000 --- a/app/javascript/mastodon/features/notifications/containers/filter_bar_container.js +++ /dev/null @@ -1,17 +0,0 @@ -import { connect } from 'react-redux'; - -import { setFilter } from '../../../actions/notifications'; -import FilterBar from '../components/filter_bar'; - -const makeMapStateToProps = state => ({ - selectedFilter: state.getIn(['settings', 'notifications', 'quickFilter', 'active']), - advancedMode: state.getIn(['settings', 'notifications', 'quickFilter', 'advanced']), -}); - -const mapDispatchToProps = (dispatch) => ({ - selectFilter (newActiveFilter) { - dispatch(setFilter(newActiveFilter)); - }, -}); - -export default connect(makeMapStateToProps, mapDispatchToProps)(FilterBar); diff --git a/app/javascript/mastodon/features/notifications/index.jsx b/app/javascript/mastodon/features/notifications/index.jsx deleted file mode 100644 index cefbd544b0..0000000000 --- a/app/javascript/mastodon/features/notifications/index.jsx +++ /dev/null @@ -1,308 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; - -import { Helmet } from 'react-helmet'; - -import { createSelector } from '@reduxjs/toolkit'; -import { List as ImmutableList } from 'immutable'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { connect } from 'react-redux'; - -import { debounce } from 'lodash'; - -import DoneAllIcon from '@/material-icons/400-24px/done_all.svg?react'; -import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg?react'; -import { compareId } from 'mastodon/compare_id'; -import { Icon } from 'mastodon/components/icon'; -import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator'; -import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; - -import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; -import { submitMarkers } from '../../actions/markers'; -import { - expandNotifications, - scrollTopNotifications, - loadPending, - mountNotifications, - unmountNotifications, - markNotificationsAsRead, -} from '../../actions/notifications'; -import Column from '../../components/column'; -import ColumnHeader from '../../components/column_header'; -import { LoadGap } from '../../components/load_gap'; -import ScrollableList from '../../components/scrollable_list'; - -import { - FilteredNotificationsBanner, - FilteredNotificationsIconButton, -} from './components/filtered_notifications_banner'; -import NotificationsPermissionBanner from './components/notifications_permission_banner'; -import ColumnSettingsContainer from './containers/column_settings_container'; -import FilterBarContainer from './containers/filter_bar_container'; -import NotificationContainer from './containers/notification_container'; - -const messages = defineMessages({ - title: { id: 'column.notifications', defaultMessage: 'Notifications' }, - markAsRead : { id: 'notifications.mark_as_read', defaultMessage: 'Mark every notification as read' }, -}); - -const getExcludedTypes = createSelector([ - state => state.getIn(['settings', 'notifications', 'shows']), -], (shows) => { - return ImmutableList(shows.filter(item => !item).keys()); -}); - -const getNotifications = createSelector([ - state => state.getIn(['settings', 'notifications', 'quickFilter', 'show']), - state => state.getIn(['settings', 'notifications', 'quickFilter', 'active']), - getExcludedTypes, - state => state.getIn(['notifications', 'items']), -], (showFilterBar, allowedType, excludedTypes, notifications) => { - if (!showFilterBar || allowedType === 'all') { - // used if user changed the notification settings after loading the notifications from the server - // otherwise a list of notifications will come pre-filtered from the backend - // we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category - return notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type'))); - } - return notifications.filter(item => item === null || allowedType === item.get('type')); -}); - -const mapStateToProps = state => ({ - notifications: getNotifications(state), - isLoading: state.getIn(['notifications', 'isLoading'], 0) > 0, - isUnread: state.getIn(['notifications', 'unread']) > 0 || state.getIn(['notifications', 'pendingItems']).size > 0, - hasMore: state.getIn(['notifications', 'hasMore']), - numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size, - lastReadId: state.getIn(['settings', 'notifications', 'showUnread']) ? state.getIn(['notifications', 'readMarkerId']) : '0', - canMarkAsRead: state.getIn(['settings', 'notifications', 'showUnread']) && state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0), - needsNotificationPermission: state.getIn(['settings', 'notifications', 'alerts']).includes(true) && state.getIn(['notifications', 'browserSupport']) && state.getIn(['notifications', 'browserPermission']) === 'default' && !state.getIn(['settings', 'notifications', 'dismissPermissionBanner']), -}); - -class Notifications extends PureComponent { - static propTypes = { - identity: identityContextPropShape, - columnId: PropTypes.string, - notifications: ImmutablePropTypes.list.isRequired, - dispatch: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - isLoading: PropTypes.bool, - isUnread: PropTypes.bool, - multiColumn: PropTypes.bool, - hasMore: PropTypes.bool, - numPending: PropTypes.number, - lastReadId: PropTypes.string, - canMarkAsRead: PropTypes.bool, - needsNotificationPermission: PropTypes.bool, - }; - - static defaultProps = { - trackScroll: true, - }; - - UNSAFE_componentWillMount() { - this.props.dispatch(mountNotifications()); - } - - componentWillUnmount () { - this.handleLoadOlder.cancel(); - this.handleScrollToTop.cancel(); - this.handleScroll.cancel(); - this.props.dispatch(scrollTopNotifications(false)); - this.props.dispatch(unmountNotifications()); - } - - handleLoadGap = (maxId) => { - this.props.dispatch(expandNotifications({ maxId })); - }; - - handleLoadOlder = debounce(() => { - const last = this.props.notifications.last(); - this.props.dispatch(expandNotifications({ maxId: last && last.get('id') })); - }, 300, { leading: true }); - - handleLoadPending = () => { - this.props.dispatch(loadPending()); - }; - - handleScrollToTop = debounce(() => { - this.props.dispatch(scrollTopNotifications(true)); - }, 100); - - handleScroll = debounce(() => { - this.props.dispatch(scrollTopNotifications(false)); - }, 100); - - handlePin = () => { - const { columnId, dispatch } = this.props; - - if (columnId) { - dispatch(removeColumn(columnId)); - } else { - dispatch(addColumn('NOTIFICATIONS', {})); - } - }; - - handleMove = (dir) => { - const { columnId, dispatch } = this.props; - dispatch(moveColumn(columnId, dir)); - }; - - handleHeaderClick = () => { - this.column.scrollTop(); - }; - - setColumnRef = c => { - this.column = c; - }; - - handleMoveUp = id => { - const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) - 1; - this._selectChild(elementIndex, true); - }; - - handleMoveDown = id => { - const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1; - this._selectChild(elementIndex, false); - }; - - _selectChild (index, align_top) { - const container = this.column.node; - const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`); - - if (element) { - if (align_top && container.scrollTop > element.offsetTop) { - element.scrollIntoView(true); - } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) { - element.scrollIntoView(false); - } - element.focus(); - } - } - - handleMarkAsRead = () => { - this.props.dispatch(markNotificationsAsRead()); - this.props.dispatch(submitMarkers({ immediate: true })); - }; - - render () { - const { intl, notifications, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props; - const pinned = !!columnId; - const emptyMessage = ; - const { signedIn } = this.props.identity; - - let scrollableContent = null; - - const filterBarContainer = signedIn - ? () - : null; - - if (isLoading && this.scrollableContent) { - scrollableContent = this.scrollableContent; - } else if (notifications.size > 0 || hasMore) { - scrollableContent = notifications.map((item, index) => item === null ? ( - 0 ? notifications.getIn([index - 1, 'id']) : null} - onClick={this.handleLoadGap} - /> - ) : ( - 0} - /> - )); - } else { - scrollableContent = null; - } - - this.scrollableContent = scrollableContent; - - let scrollContainer; - - const prepend = ( - <> - {needsNotificationPermission && } - - - ); - - if (signedIn) { - scrollContainer = ( - - {scrollableContent} - - ); - } else { - scrollContainer = ; - } - - const extraButton = ( - <> - - {canMarkAsRead && ( - - )} - - ); - - return ( - - - - - - {filterBarContainer} - - {scrollContainer} - - - {intl.formatMessage(messages.title)} - - - - ); - } - -} - -export default connect(mapStateToProps)(withIdentity(injectIntl(Notifications))); diff --git a/app/javascript/mastodon/features/notifications_wrapper.jsx b/app/javascript/mastodon/features/notifications_wrapper.jsx deleted file mode 100644 index 50383d5ebf..0000000000 --- a/app/javascript/mastodon/features/notifications_wrapper.jsx +++ /dev/null @@ -1,9 +0,0 @@ -import Notifications_v2 from 'mastodon/features/notifications_v2'; - -export const NotificationsWrapper = (props) => { - return ( - - ); -}; - -export default NotificationsWrapper; \ No newline at end of file diff --git a/app/javascript/mastodon/features/onboarding/components/step.jsx b/app/javascript/mastodon/features/onboarding/components/step.jsx deleted file mode 100644 index a2a1653b8a..0000000000 --- a/app/javascript/mastodon/features/onboarding/components/step.jsx +++ /dev/null @@ -1,57 +0,0 @@ -import PropTypes from 'prop-types'; - -import { Link } from 'react-router-dom'; - -import ArrowRightAltIcon from '@/material-icons/400-24px/arrow_right_alt.svg?react'; -import CheckIcon from '@/material-icons/400-24px/done.svg?react'; -import { Icon } from 'mastodon/components/icon'; - -export const Step = ({ label, description, icon, iconComponent, completed, onClick, href, to }) => { - const content = ( - <> -
- -
- -
-
{label}
-

{description}

-
- -
- {completed ? : } -
- - ); - - if (href) { - return ( - - {content} - - ); - } else if (to) { - return ( - - {content} - - ); - } - - return ( - - ); -}; - -Step.propTypes = { - label: PropTypes.node, - description: PropTypes.node, - icon: PropTypes.string, - iconComponent: PropTypes.func, - completed: PropTypes.bool, - href: PropTypes.string, - to: PropTypes.string, - onClick: PropTypes.func, -}; diff --git a/app/javascript/mastodon/features/onboarding/follows.jsx b/app/javascript/mastodon/features/onboarding/follows.jsx deleted file mode 100644 index e23a335c06..0000000000 --- a/app/javascript/mastodon/features/onboarding/follows.jsx +++ /dev/null @@ -1,62 +0,0 @@ -import { useEffect } from 'react'; - -import { FormattedMessage } from 'react-intl'; - -import { Link } from 'react-router-dom'; - -import { useDispatch } from 'react-redux'; - - -import { fetchSuggestions } from 'mastodon/actions/suggestions'; -import { markAsPartial } from 'mastodon/actions/timelines'; -import { ColumnBackButton } from 'mastodon/components/column_back_button'; -import { EmptyAccount } from 'mastodon/components/empty_account'; -import Account from 'mastodon/containers/account_container'; -import { useAppSelector } from 'mastodon/store'; - -export const Follows = () => { - const dispatch = useDispatch(); - const isLoading = useAppSelector(state => state.getIn(['suggestions', 'isLoading'])); - const suggestions = useAppSelector(state => state.getIn(['suggestions', 'items'])); - - useEffect(() => { - dispatch(fetchSuggestions(true)); - - return () => { - dispatch(markAsPartial('home')); - }; - }, [dispatch]); - - let loadedContent; - - if (isLoading) { - loadedContent = (new Array(8)).fill().map((_, i) => ); - } else if (suggestions.isEmpty()) { - loadedContent =
; - } else { - loadedContent = suggestions.map(suggestion => ); - } - - return ( - <> - - -
-
-

-

-
- -
- {loadedContent} -
- -

{chunks} }} />

- -
- -
-
- - ); -}; diff --git a/app/javascript/mastodon/features/onboarding/follows.tsx b/app/javascript/mastodon/features/onboarding/follows.tsx new file mode 100644 index 0000000000..25ee48c8ac --- /dev/null +++ b/app/javascript/mastodon/features/onboarding/follows.tsx @@ -0,0 +1,191 @@ +import { useEffect, useState, useCallback, useRef } from 'react'; + +import { FormattedMessage, useIntl, defineMessages } from 'react-intl'; + +import { Helmet } from 'react-helmet'; +import { Link } from 'react-router-dom'; + +import { useDebouncedCallback } from 'use-debounce'; + +import PersonIcon from '@/material-icons/400-24px/person.svg?react'; +import { fetchRelationships } from 'mastodon/actions/accounts'; +import { importFetchedAccounts } from 'mastodon/actions/importer'; +import { fetchSuggestions } from 'mastodon/actions/suggestions'; +import { markAsPartial } from 'mastodon/actions/timelines'; +import { apiRequest } from 'mastodon/api'; +import type { ApiAccountJSON } from 'mastodon/api_types/accounts'; +import Column from 'mastodon/components/column'; +import { ColumnHeader } from 'mastodon/components/column_header'; +import { ColumnSearchHeader } from 'mastodon/components/column_search_header'; +import ScrollableList from 'mastodon/components/scrollable_list'; +import Account from 'mastodon/containers/account_container'; +import { useAppSelector, useAppDispatch } from 'mastodon/store'; + +const messages = defineMessages({ + title: { + id: 'onboarding.follows.title', + defaultMessage: 'Follow people to get started', + }, + search: { id: 'onboarding.follows.search', defaultMessage: 'Search' }, + back: { id: 'onboarding.follows.back', defaultMessage: 'Back' }, +}); + +type Mode = 'remove' | 'add'; + +export const Follows: React.FC<{ + multiColumn?: boolean; +}> = ({ multiColumn }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const isLoading = useAppSelector((state) => state.suggestions.isLoading); + const suggestions = useAppSelector((state) => state.suggestions.items); + const [searchAccountIds, setSearchAccountIds] = useState([]); + const [mode, setMode] = useState('remove'); + const [isLoadingSearch, setIsLoadingSearch] = useState(false); + const [isSearching, setIsSearching] = useState(false); + + useEffect(() => { + void dispatch(fetchSuggestions()); + + return () => { + dispatch(markAsPartial('home')); + }; + }, [dispatch]); + + const handleSearchClick = useCallback(() => { + setMode('add'); + }, [setMode]); + + const handleDismissSearchClick = useCallback(() => { + setMode('remove'); + setIsSearching(false); + }, [setMode, setIsSearching]); + + const searchRequestRef = useRef(null); + + const handleSearch = useDebouncedCallback( + (value: string) => { + if (searchRequestRef.current) { + searchRequestRef.current.abort(); + } + + if (value.trim().length === 0) { + setIsSearching(false); + setSearchAccountIds([]); + return; + } + + setIsSearching(true); + setIsLoadingSearch(true); + + searchRequestRef.current = new AbortController(); + + void apiRequest('GET', 'v1/accounts/search', { + signal: searchRequestRef.current.signal, + params: { + q: value, + }, + }) + .then((data) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchRelationships(data.map((a) => a.id))); + setSearchAccountIds(data.map((a) => a.id)); + setIsLoadingSearch(false); + return ''; + }) + .catch(() => { + setIsLoadingSearch(false); + }); + }, + 500, + { leading: true, trailing: true }, + ); + + let displayedAccountIds: string[]; + + if (mode === 'add' && isSearching) { + displayedAccountIds = searchAccountIds; + } else { + displayedAccountIds = suggestions.map( + (suggestion) => suggestion.account_id, + ); + } + + return ( + + + + + + + {displayedAccountIds.length > 0 &&
} + +
+ + + +
+ + } + emptyMessage={ + mode === 'remove' ? ( + + ) : ( + + ) + } + > + {displayedAccountIds.map((accountId) => ( + + ))} + + + + {intl.formatMessage(messages.title)} + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default Follows; diff --git a/app/javascript/mastodon/features/onboarding/index.jsx b/app/javascript/mastodon/features/onboarding/index.jsx deleted file mode 100644 index d100a1c3d5..0000000000 --- a/app/javascript/mastodon/features/onboarding/index.jsx +++ /dev/null @@ -1,91 +0,0 @@ -import { useCallback } from 'react'; - -import { FormattedMessage, useIntl, defineMessages } from 'react-intl'; - -import { Helmet } from 'react-helmet'; -import { Link, Switch, Route } from 'react-router-dom'; - -import { useDispatch } from 'react-redux'; - - -import illustration from '@/images/elephant_ui_conversation.svg'; -import AccountCircleIcon from '@/material-icons/400-24px/account_circle.svg?react'; -import ArrowRightAltIcon from '@/material-icons/400-24px/arrow_right_alt.svg?react'; -import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react'; -import EditNoteIcon from '@/material-icons/400-24px/edit_note.svg?react'; -import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react'; -import { focusCompose } from 'mastodon/actions/compose'; -import { Icon } from 'mastodon/components/icon'; -import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator'; -import Column from 'mastodon/features/ui/components/column'; -import { me } from 'mastodon/initial_state'; -import { useAppSelector } from 'mastodon/store'; -import { assetHost } from 'mastodon/utils/config'; - -import { Step } from './components/step'; -import { Follows } from './follows'; -import { Profile } from './profile'; -import { Share } from './share'; - -const messages = defineMessages({ - template: { id: 'onboarding.compose.template', defaultMessage: 'Hello #Mastodon!' }, -}); - -const Onboarding = () => { - const account = useAppSelector(state => state.getIn(['accounts', me])); - const dispatch = useDispatch(); - const intl = useIntl(); - - const handleComposeClick = useCallback(() => { - dispatch(focusCompose(intl.formatMessage(messages.template))); - }, [dispatch, intl]); - - return ( - - {account ? ( - - -
-
- -

-

-
- -
- 0 && account.get('note').length > 0)} icon='address-book-o' iconComponent={AccountCircleIcon} label={} description={} /> - = 1} icon='user-plus' iconComponent={PersonAddIcon} label={} description={} /> - = 1} icon='pencil-square-o' iconComponent={EditNoteIcon} label={} description={ }} />} /> - } description={} /> -
- -

- -
- - - - - - - - - -
-
-
- - - - -
- ) : } - - - - -
- ); -}; - -export default Onboarding; diff --git a/app/javascript/mastodon/features/onboarding/profile.jsx b/app/javascript/mastodon/features/onboarding/profile.jsx deleted file mode 100644 index 14250ae39b..0000000000 --- a/app/javascript/mastodon/features/onboarding/profile.jsx +++ /dev/null @@ -1,162 +0,0 @@ -import { useState, useMemo, useCallback, createRef } from 'react'; - -import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; - -import classNames from 'classnames'; -import { useHistory } from 'react-router-dom'; - - -import { useDispatch } from 'react-redux'; - -import Toggle from 'react-toggle'; - -import AddPhotoAlternateIcon from '@/material-icons/400-24px/add_photo_alternate.svg?react'; -import EditIcon from '@/material-icons/400-24px/edit.svg?react'; -import { updateAccount } from 'mastodon/actions/accounts'; -import { Button } from 'mastodon/components/button'; -import { ColumnBackButton } from 'mastodon/components/column_back_button'; -import { Icon } from 'mastodon/components/icon'; -import { LoadingIndicator } from 'mastodon/components/loading_indicator'; -import { me } from 'mastodon/initial_state'; -import { useAppSelector } from 'mastodon/store'; -import { unescapeHTML } from 'mastodon/utils/html'; - -const messages = defineMessages({ - uploadHeader: { id: 'onboarding.profile.upload_header', defaultMessage: 'Upload profile header' }, - uploadAvatar: { id: 'onboarding.profile.upload_avatar', defaultMessage: 'Upload profile picture' }, -}); - -const nullIfMissing = path => path.endsWith('missing.png') ? null : path; - -export const Profile = () => { - const account = useAppSelector(state => state.getIn(['accounts', me])); - const [displayName, setDisplayName] = useState(account.get('display_name')); - const [note, setNote] = useState(unescapeHTML(account.get('note'))); - const [avatar, setAvatar] = useState(null); - const [header, setHeader] = useState(null); - const [discoverable, setDiscoverable] = useState(account.get('discoverable')); - const [isSaving, setIsSaving] = useState(false); - const [errors, setErrors] = useState(); - const avatarFileRef = createRef(); - const headerFileRef = createRef(); - const dispatch = useDispatch(); - const intl = useIntl(); - const history = useHistory(); - - const handleDisplayNameChange = useCallback(e => { - setDisplayName(e.target.value); - }, [setDisplayName]); - - const handleNoteChange = useCallback(e => { - setNote(e.target.value); - }, [setNote]); - - const handleDiscoverableChange = useCallback(e => { - setDiscoverable(e.target.checked); - }, [setDiscoverable]); - - const handleAvatarChange = useCallback(e => { - setAvatar(e.target?.files?.[0]); - }, [setAvatar]); - - const handleHeaderChange = useCallback(e => { - setHeader(e.target?.files?.[0]); - }, [setHeader]); - - const avatarPreview = useMemo(() => avatar ? URL.createObjectURL(avatar) : nullIfMissing(account.get('avatar')), [avatar, account]); - const headerPreview = useMemo(() => header ? URL.createObjectURL(header) : nullIfMissing(account.get('header')), [header, account]); - - const handleSubmit = useCallback(() => { - setIsSaving(true); - - dispatch(updateAccount({ - displayName, - note, - avatar, - header, - discoverable, - indexable: discoverable, - })).then(() => history.push('/start/follows')).catch(err => { - setIsSaving(false); - setErrors(err.response.data.details); - }); - }, [dispatch, displayName, note, avatar, header, discoverable, history]); - - return ( - <> - - -
-
-

-

-
- -
-
- - - -
- -
- - -
- -
-
- -
- - -
-