diff --git a/app/soapbox/actions/search.ts b/app/soapbox/actions/search.ts index 1eb749e78..0268ef8fd 100644 --- a/app/soapbox/actions/search.ts +++ b/app/soapbox/actions/search.ts @@ -22,6 +22,8 @@ const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST'; const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS'; const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL'; +const SEARCH_ACCOUNT_SET = 'SEARCH_ACCOUNT_SET'; + const changeSearch = (value: string) => (dispatch: AppDispatch) => { // If backspaced all the way, clear the search @@ -43,6 +45,7 @@ const submitSearch = (filter?: SearchFilter) => (dispatch: AppDispatch, getState: () => RootState) => { const value = getState().search.value; const type = filter || getState().search.filter || 'accounts'; + const accountId = getState().search.accountId; // An empty search doesn't return any results if (value.length === 0) { @@ -51,13 +54,17 @@ const submitSearch = (filter?: SearchFilter) => dispatch(fetchSearchRequest(value)); + const params: Record = { + q: value, + resolve: true, + limit: 20, + type, + }; + + if (accountId) params.account_id = accountId; + api(getState).get('/api/v2/search', { - params: { - q: value, - resolve: true, - limit: 20, - type, - }, + params, }).then(response => { if (response.data.accounts) { dispatch(importFetchedAccounts(response.data.accounts)); @@ -151,6 +158,11 @@ const showSearch = () => ({ type: SEARCH_SHOW, }); +const setSearchAccount = (accountId: string) => ({ + type: SEARCH_ACCOUNT_SET, + accountId, +}); + export { SEARCH_CHANGE, SEARCH_CLEAR, @@ -162,6 +174,7 @@ export { SEARCH_EXPAND_REQUEST, SEARCH_EXPAND_SUCCESS, SEARCH_EXPAND_FAIL, + SEARCH_ACCOUNT_SET, changeSearch, clearSearch, submitSearch, @@ -174,4 +187,5 @@ export { expandSearchSuccess, expandSearchFail, showSearch, + setSearchAccount, }; diff --git a/app/soapbox/features/account/components/header.js b/app/soapbox/features/account/components/header.js index d5940d85c..aaeed52ee 100644 --- a/app/soapbox/features/account/components/header.js +++ b/app/soapbox/features/account/components/header.js @@ -64,6 +64,7 @@ const messages = defineMessages({ demoteToUser: { id: 'admin.users.actions.demote_to_user', defaultMessage: 'Demote @{name} to a regular user' }, suggestUser: { id: 'admin.users.actions.suggest_user', defaultMessage: 'Suggest @{name}' }, unsuggestUser: { id: 'admin.users.actions.unsuggest_user', defaultMessage: 'Unsuggest @{name}' }, + search: { id: 'account.search', defaultMessage: 'Search from @{name}' }, }); const mapStateToProps = state => { @@ -274,6 +275,14 @@ class Header extends ImmutablePureComponent { }); } + if (features.searchFromAccount) { + menu.push({ + text: intl.formatMessage(messages.search, { name: account.get('username') }), + action: this.props.onSearch, + icon: require('@tabler/icons/search.svg'), + }); + } + if (features.removeFromFollowers && account.relationship?.followed_by) { menu.push({ text: intl.formatMessage(messages.removeFromFollowers), diff --git a/app/soapbox/features/account_timeline/components/header.js b/app/soapbox/features/account_timeline/components/header.js index af65f1688..493bd7a39 100644 --- a/app/soapbox/features/account_timeline/components/header.js +++ b/app/soapbox/features/account_timeline/components/header.js @@ -26,6 +26,7 @@ class Header extends ImmutablePureComponent { onEndorseToggle: PropTypes.func.isRequired, onAddToList: PropTypes.func.isRequired, onRemoveFromFollowers: PropTypes.func.isRequired, + onSearch: PropTypes.func.isRequired, username: PropTypes.string, history: PropTypes.object, }; @@ -146,6 +147,10 @@ class Header extends ImmutablePureComponent { this.props.onRemoveFromFollowers(this.props.account); } + handleSearch = () => { + this.props.onSearch(this.props.account, this.props.history); + } + render() { const { account } = this.props; const moved = (account) ? account.get('moved') : false; @@ -183,6 +188,7 @@ class Header extends ImmutablePureComponent { onUnsuggestUser={this.handleUnsuggestUser} onShowNote={this.handleShowNote} onRemoveFromFollowers={this.handleRemoveFromFollowers} + onSearch={this.handleSearch} username={this.props.username} /> diff --git a/app/soapbox/features/account_timeline/containers/header_container.js b/app/soapbox/features/account_timeline/containers/header_container.js index 2189b9a46..af113d8f2 100644 --- a/app/soapbox/features/account_timeline/containers/header_container.js +++ b/app/soapbox/features/account_timeline/containers/header_container.js @@ -36,6 +36,7 @@ import { openModal } from 'soapbox/actions/modals'; import { deactivateUserModal, deleteUserModal } from 'soapbox/actions/moderation'; import { initMuteModal } from 'soapbox/actions/mutes'; import { initReport } from 'soapbox/actions/reports'; +import { setSearchAccount } from 'soapbox/actions/search'; import { getSettings } from 'soapbox/actions/settings'; import snackbar from 'soapbox/actions/snackbar'; import { makeGetAccount } from 'soapbox/selectors'; @@ -291,6 +292,13 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ } }); }, + + onSearch(account, router) { + dispatch((dispatch) => { + dispatch(setSearchAccount(account.id)); + router.push('/search'); + }); + }, }); export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header)); diff --git a/app/soapbox/features/compose/components/search_results.tsx b/app/soapbox/features/compose/components/search_results.tsx index a534911ba..ddcf2a563 100644 --- a/app/soapbox/features/compose/components/search_results.tsx +++ b/app/soapbox/features/compose/components/search_results.tsx @@ -2,11 +2,12 @@ import classNames from 'classnames'; import React, { useEffect, useRef } from 'react'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; -import { expandSearch, setFilter } from 'soapbox/actions/search'; +import { clearSearch, expandSearch, setFilter } from 'soapbox/actions/search'; import { fetchTrendingStatuses } from 'soapbox/actions/trending_statuses'; import Hashtag from 'soapbox/components/hashtag'; +import IconButton from 'soapbox/components/icon_button'; import ScrollableList from 'soapbox/components/scrollable_list'; -import { Tabs } from 'soapbox/components/ui'; +import { HStack, Tabs, Text } from 'soapbox/components/ui'; import AccountContainer from 'soapbox/containers/account_container'; import StatusContainer from 'soapbox/containers/status_container'; import PlaceholderAccount from 'soapbox/features/placeholder/components/placeholder_account'; @@ -37,9 +38,13 @@ const SearchResults = () => { const trends = useAppSelector((state) => state.trends.items); const submitted = useAppSelector((state) => state.search.submitted); const selectedFilter = useAppSelector((state) => state.search.filter); + const filterByAccount = useAppSelector((state) => state.search.accountId); + const account = useAppSelector((state) => state.accounts.get(filterByAccount)?.acct); const handleLoadMore = () => dispatch(expandSearch(selectedFilter)); + const handleClearSearch = () => dispatch(clearSearch()); + const selectFilter = (newActiveFilter: SearchFilter) => dispatch(setFilter(newActiveFilter)); const renderFilterBar = () => { @@ -189,7 +194,18 @@ const SearchResults = () => { return ( <> - {renderFilterBar()} + {filterByAccount ? ( + + + + + + + ) : renderFilterBar()} {noResultsMessage || ( ; @@ -120,6 +122,8 @@ export default function search(state = ReducerRecord(), action: AnyAction) { return state.setIn(['results', `${action.searchType}Loaded`], false); case SEARCH_EXPAND_SUCCESS: return paginateResults(state, action.searchType, action.results, action.searchTerm); + case SEARCH_ACCOUNT_SET: + return ReducerRecord({ accountId: action.accountId, filter: 'statuses' }); default: return state; } diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index a05640266..2043e3545 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -536,6 +536,16 @@ const getInstanceFeatures = (instance: Instance) => { */ scopes: v.software === PLEROMA ? 'read write follow push admin' : 'read write follow push', + /** + * Ability to search statuses from the given account. + * @see {@link https://docs.joinmastodon.org/methods/search/} + * @see POST /api/v2/search + */ + searchFromAccount: any([ + v.software === MASTODON && gte(v.version, '2.8.0'), + v.software === PLEROMA && gte(v.version, '1.0.0'), + ]), + /** * Ability to manage account security settings. * @see POST /api/pleroma/change_password