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 a1b50ffaf8..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 &&
} + <> + {displayedAccountIds.length > 0 &&
} -
- - - -
- - ) +
+ + + +
+ } emptyMessage={ mode === 'remove' ? ( 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 ( - <> - - -
-
-

-

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