mirror of
https://github.com/mastodon/mastodon.git
synced 2024-12-29 14:35:06 +01:00
Change search to use query params in web UI (#32949)
This commit is contained in:
parent
708919ee93
commit
0636bcdbe1
@ -2,6 +2,8 @@ import { useCallback } from 'react';
|
||||
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { isFulfilled, isRejected } from '@reduxjs/toolkit';
|
||||
|
||||
import { openURL } from 'mastodon/actions/search';
|
||||
import { useAppDispatch } from 'mastodon/store';
|
||||
|
||||
@ -28,12 +30,22 @@ export const useLinks = () => {
|
||||
);
|
||||
|
||||
const handleMentionClick = useCallback(
|
||||
(element: HTMLAnchorElement) => {
|
||||
dispatch(
|
||||
openURL(element.href, history, () => {
|
||||
async (element: HTMLAnchorElement) => {
|
||||
const result = await dispatch(openURL({ url: element.href }));
|
||||
|
||||
if (isFulfilled(result)) {
|
||||
if (result.payload.accounts[0]) {
|
||||
history.push(`/@${result.payload.accounts[0].acct}`);
|
||||
} else if (result.payload.statuses[0]) {
|
||||
history.push(
|
||||
`/@${result.payload.statuses[0].account.acct}/${result.payload.statuses[0].id}`,
|
||||
);
|
||||
} else {
|
||||
window.location.href = element.href;
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else if (isRejected(result)) {
|
||||
window.location.href = element.href;
|
||||
}
|
||||
},
|
||||
[dispatch, history],
|
||||
);
|
||||
@ -48,7 +60,7 @@ export const useLinks = () => {
|
||||
|
||||
if (isMentionClick(target)) {
|
||||
e.preventDefault();
|
||||
handleMentionClick(target);
|
||||
void handleMentionClick(target);
|
||||
} else if (isHashtagClick(target)) {
|
||||
e.preventDefault();
|
||||
handleHashtagClick(target);
|
||||
|
@ -1,215 +0,0 @@
|
||||
import { fromJS } from 'immutable';
|
||||
|
||||
import { searchHistory } from 'mastodon/settings';
|
||||
|
||||
import api from '../api';
|
||||
|
||||
import { fetchRelationships } from './accounts';
|
||||
import { importFetchedAccounts, importFetchedStatuses } from './importer';
|
||||
|
||||
export const SEARCH_CHANGE = 'SEARCH_CHANGE';
|
||||
export const SEARCH_CLEAR = 'SEARCH_CLEAR';
|
||||
export const SEARCH_SHOW = 'SEARCH_SHOW';
|
||||
|
||||
export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST';
|
||||
export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS';
|
||||
export const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL';
|
||||
|
||||
export const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST';
|
||||
export const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS';
|
||||
export const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL';
|
||||
|
||||
export const SEARCH_HISTORY_UPDATE = 'SEARCH_HISTORY_UPDATE';
|
||||
|
||||
export function changeSearch(value) {
|
||||
return {
|
||||
type: SEARCH_CHANGE,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearSearch() {
|
||||
return {
|
||||
type: SEARCH_CLEAR,
|
||||
};
|
||||
}
|
||||
|
||||
export function submitSearch(type) {
|
||||
return (dispatch, getState) => {
|
||||
const value = getState().getIn(['search', 'value']);
|
||||
const signedIn = !!getState().getIn(['meta', 'me']);
|
||||
|
||||
if (value.length === 0) {
|
||||
dispatch(fetchSearchSuccess({ accounts: [], statuses: [], hashtags: [] }, '', type));
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(fetchSearchRequest(type));
|
||||
|
||||
api().get('/api/v2/search', {
|
||||
params: {
|
||||
q: value,
|
||||
resolve: signedIn,
|
||||
limit: 11,
|
||||
type,
|
||||
},
|
||||
}).then(response => {
|
||||
if (response.data.accounts) {
|
||||
dispatch(importFetchedAccounts(response.data.accounts));
|
||||
}
|
||||
|
||||
if (response.data.statuses) {
|
||||
dispatch(importFetchedStatuses(response.data.statuses));
|
||||
}
|
||||
|
||||
dispatch(fetchSearchSuccess(response.data, value, type));
|
||||
dispatch(fetchRelationships(response.data.accounts.map(item => item.id)));
|
||||
}).catch(error => {
|
||||
dispatch(fetchSearchFail(error));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchSearchRequest(searchType) {
|
||||
return {
|
||||
type: SEARCH_FETCH_REQUEST,
|
||||
searchType,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchSearchSuccess(results, searchTerm, searchType) {
|
||||
return {
|
||||
type: SEARCH_FETCH_SUCCESS,
|
||||
results,
|
||||
searchType,
|
||||
searchTerm,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchSearchFail(error) {
|
||||
return {
|
||||
type: SEARCH_FETCH_FAIL,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export const expandSearch = type => (dispatch, getState) => {
|
||||
const value = getState().getIn(['search', 'value']);
|
||||
const offset = getState().getIn(['search', 'results', type]).size - 1;
|
||||
|
||||
dispatch(expandSearchRequest(type));
|
||||
|
||||
api().get('/api/v2/search', {
|
||||
params: {
|
||||
q: value,
|
||||
type,
|
||||
offset,
|
||||
limit: 11,
|
||||
},
|
||||
}).then(({ data }) => {
|
||||
if (data.accounts) {
|
||||
dispatch(importFetchedAccounts(data.accounts));
|
||||
}
|
||||
|
||||
if (data.statuses) {
|
||||
dispatch(importFetchedStatuses(data.statuses));
|
||||
}
|
||||
|
||||
dispatch(expandSearchSuccess(data, value, type));
|
||||
dispatch(fetchRelationships(data.accounts.map(item => item.id)));
|
||||
}).catch(error => {
|
||||
dispatch(expandSearchFail(error));
|
||||
});
|
||||
};
|
||||
|
||||
export const expandSearchRequest = (searchType) => ({
|
||||
type: SEARCH_EXPAND_REQUEST,
|
||||
searchType,
|
||||
});
|
||||
|
||||
export const expandSearchSuccess = (results, searchTerm, searchType) => ({
|
||||
type: SEARCH_EXPAND_SUCCESS,
|
||||
results,
|
||||
searchTerm,
|
||||
searchType,
|
||||
});
|
||||
|
||||
export const expandSearchFail = error => ({
|
||||
type: SEARCH_EXPAND_FAIL,
|
||||
error,
|
||||
});
|
||||
|
||||
export const showSearch = () => ({
|
||||
type: SEARCH_SHOW,
|
||||
});
|
||||
|
||||
export const openURL = (value, history, onFailure) => (dispatch, getState) => {
|
||||
const signedIn = !!getState().getIn(['meta', 'me']);
|
||||
|
||||
if (!signedIn) {
|
||||
if (onFailure) {
|
||||
onFailure();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(fetchSearchRequest());
|
||||
|
||||
api().get('/api/v2/search', { params: { q: value, resolve: true } }).then(response => {
|
||||
if (response.data.accounts?.length > 0) {
|
||||
dispatch(importFetchedAccounts(response.data.accounts));
|
||||
history.push(`/@${response.data.accounts[0].acct}`);
|
||||
} else if (response.data.statuses?.length > 0) {
|
||||
dispatch(importFetchedStatuses(response.data.statuses));
|
||||
history.push(`/@${response.data.statuses[0].account.acct}/${response.data.statuses[0].id}`);
|
||||
} else if (onFailure) {
|
||||
onFailure();
|
||||
}
|
||||
|
||||
dispatch(fetchSearchSuccess(response.data, value));
|
||||
}).catch(err => {
|
||||
dispatch(fetchSearchFail(err));
|
||||
|
||||
if (onFailure) {
|
||||
onFailure();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const clickSearchResult = (q, type) => (dispatch, getState) => {
|
||||
const previous = getState().getIn(['search', 'recent']);
|
||||
|
||||
if (previous.some(x => x.get('q') === q && x.get('type') === type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const me = getState().getIn(['meta', 'me']);
|
||||
const current = previous.add(fromJS({ type, q })).takeLast(4);
|
||||
|
||||
searchHistory.set(me, current.toJS());
|
||||
dispatch(updateSearchHistory(current));
|
||||
};
|
||||
|
||||
export const forgetSearchResult = q => (dispatch, getState) => {
|
||||
const previous = getState().getIn(['search', 'recent']);
|
||||
const me = getState().getIn(['meta', 'me']);
|
||||
const current = previous.filterNot(result => result.get('q') === q);
|
||||
|
||||
searchHistory.set(me, current.toJS());
|
||||
dispatch(updateSearchHistory(current));
|
||||
};
|
||||
|
||||
export const updateSearchHistory = recent => ({
|
||||
type: SEARCH_HISTORY_UPDATE,
|
||||
recent,
|
||||
});
|
||||
|
||||
export const hydrateSearch = () => (dispatch, getState) => {
|
||||
const me = getState().getIn(['meta', 'me']);
|
||||
const history = searchHistory.get(me);
|
||||
|
||||
if (history !== null) {
|
||||
dispatch(updateSearchHistory(history));
|
||||
}
|
||||
};
|
151
app/javascript/mastodon/actions/search.ts
Normal file
151
app/javascript/mastodon/actions/search.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { apiGetSearch } from 'mastodon/api/search';
|
||||
import type { ApiSearchType } from 'mastodon/api_types/search';
|
||||
import type {
|
||||
RecentSearch,
|
||||
SearchType as RecentSearchType,
|
||||
} from 'mastodon/models/search';
|
||||
import { searchHistory } from 'mastodon/settings';
|
||||
import {
|
||||
createDataLoadingThunk,
|
||||
createAppAsyncThunk,
|
||||
} from 'mastodon/store/typed_functions';
|
||||
|
||||
import { fetchRelationships } from './accounts';
|
||||
import { importFetchedAccounts, importFetchedStatuses } from './importer';
|
||||
|
||||
export const SEARCH_HISTORY_UPDATE = 'SEARCH_HISTORY_UPDATE';
|
||||
|
||||
export const submitSearch = createDataLoadingThunk(
|
||||
'search/submit',
|
||||
async ({ q, type }: { q: string; type?: ApiSearchType }, { getState }) => {
|
||||
const signedIn = !!getState().meta.get('me');
|
||||
|
||||
return apiGetSearch({
|
||||
q,
|
||||
type,
|
||||
resolve: signedIn,
|
||||
limit: 11,
|
||||
});
|
||||
},
|
||||
(data, { dispatch }) => {
|
||||
if (data.accounts.length > 0) {
|
||||
dispatch(importFetchedAccounts(data.accounts));
|
||||
dispatch(fetchRelationships(data.accounts.map((account) => account.id)));
|
||||
}
|
||||
|
||||
if (data.statuses.length > 0) {
|
||||
dispatch(importFetchedStatuses(data.statuses));
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
{
|
||||
useLoadingBar: false,
|
||||
},
|
||||
);
|
||||
|
||||
export const expandSearch = createDataLoadingThunk(
|
||||
'search/expand',
|
||||
async ({ type }: { type: ApiSearchType }, { getState }) => {
|
||||
const q = getState().search.q;
|
||||
const results = getState().search.results;
|
||||
const offset = results?.[type].length;
|
||||
|
||||
return apiGetSearch({
|
||||
q,
|
||||
type,
|
||||
limit: 11,
|
||||
offset,
|
||||
});
|
||||
},
|
||||
(data, { dispatch }) => {
|
||||
if (data.accounts.length > 0) {
|
||||
dispatch(importFetchedAccounts(data.accounts));
|
||||
dispatch(fetchRelationships(data.accounts.map((account) => account.id)));
|
||||
}
|
||||
|
||||
if (data.statuses.length > 0) {
|
||||
dispatch(importFetchedStatuses(data.statuses));
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
{
|
||||
useLoadingBar: true,
|
||||
},
|
||||
);
|
||||
|
||||
export const openURL = createDataLoadingThunk(
|
||||
'search/openURL',
|
||||
({ url }: { url: string }, { getState }) => {
|
||||
const signedIn = !!getState().meta.get('me');
|
||||
|
||||
return apiGetSearch({
|
||||
q: url,
|
||||
resolve: signedIn,
|
||||
limit: 1,
|
||||
});
|
||||
},
|
||||
(data, { dispatch }) => {
|
||||
if (data.accounts.length > 0) {
|
||||
dispatch(importFetchedAccounts(data.accounts));
|
||||
} else if (data.statuses.length > 0) {
|
||||
dispatch(importFetchedStatuses(data.statuses));
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
{
|
||||
useLoadingBar: true,
|
||||
},
|
||||
);
|
||||
|
||||
export const clickSearchResult = createAppAsyncThunk(
|
||||
'search/clickResult',
|
||||
(
|
||||
{ q, type }: { q: string; type?: RecentSearchType },
|
||||
{ dispatch, getState },
|
||||
) => {
|
||||
const previous = getState().search.recent;
|
||||
|
||||
if (previous.some((x) => x.q === q && x.type === type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const me = getState().meta.get('me') as string;
|
||||
const current = [{ type, q }, ...previous].slice(0, 4);
|
||||
|
||||
searchHistory.set(me, current);
|
||||
dispatch(updateSearchHistory(current));
|
||||
},
|
||||
);
|
||||
|
||||
export const forgetSearchResult = createAppAsyncThunk(
|
||||
'search/forgetResult',
|
||||
(q: string, { dispatch, getState }) => {
|
||||
const previous = getState().search.recent;
|
||||
const me = getState().meta.get('me') as string;
|
||||
const current = previous.filter((result) => result.q !== q);
|
||||
|
||||
searchHistory.set(me, current);
|
||||
dispatch(updateSearchHistory(current));
|
||||
},
|
||||
);
|
||||
|
||||
export const updateSearchHistory = createAction<RecentSearch[]>(
|
||||
'search/updateHistory',
|
||||
);
|
||||
|
||||
export const hydrateSearch = createAppAsyncThunk(
|
||||
'search/hydrate',
|
||||
(_args, { dispatch, getState }) => {
|
||||
const me = getState().meta.get('me') as string;
|
||||
const history = searchHistory.get(me) as RecentSearch[] | null;
|
||||
|
||||
if (history !== null) {
|
||||
dispatch(updateSearchHistory(history));
|
||||
}
|
||||
},
|
||||
);
|
16
app/javascript/mastodon/api/search.ts
Normal file
16
app/javascript/mastodon/api/search.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { apiRequestGet } from 'mastodon/api';
|
||||
import type {
|
||||
ApiSearchType,
|
||||
ApiSearchResultsJSON,
|
||||
} from 'mastodon/api_types/search';
|
||||
|
||||
export const apiGetSearch = (params: {
|
||||
q: string;
|
||||
resolve?: boolean;
|
||||
type?: ApiSearchType;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) =>
|
||||
apiRequestGet<ApiSearchResultsJSON>('v2/search', {
|
||||
...params,
|
||||
});
|
11
app/javascript/mastodon/api_types/search.ts
Normal file
11
app/javascript/mastodon/api_types/search.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import type { ApiAccountJSON } from './accounts';
|
||||
import type { ApiStatusJSON } from './statuses';
|
||||
import type { ApiHashtagJSON } from './tags';
|
||||
|
||||
export type ApiSearchType = 'accounts' | 'statuses' | 'hashtags';
|
||||
|
||||
export interface ApiSearchResultsJSON {
|
||||
accounts: ApiAccountJSON[];
|
||||
statuses: ApiStatusJSON[];
|
||||
hashtags: ApiHashtagJSON[];
|
||||
}
|
@ -12,6 +12,7 @@ import { Sparklines, SparklinesCurve } from 'react-sparklines';
|
||||
|
||||
import { ShortNumber } from 'mastodon/components/short_number';
|
||||
import { Skeleton } from 'mastodon/components/skeleton';
|
||||
import type { Hashtag as HashtagType } from 'mastodon/models/tags';
|
||||
|
||||
interface SilentErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
@ -80,6 +81,22 @@ export const ImmutableHashtag = ({ hashtag }: ImmutableHashtagProps) => (
|
||||
/>
|
||||
);
|
||||
|
||||
export const CompatibilityHashtag: React.FC<{
|
||||
hashtag: HashtagType;
|
||||
}> = ({ hashtag }) => (
|
||||
<Hashtag
|
||||
name={hashtag.name}
|
||||
to={`/tags/${hashtag.name}`}
|
||||
people={
|
||||
(hashtag.history[0].accounts as unknown as number) * 1 +
|
||||
((hashtag.history[1]?.accounts ?? 0) as unknown as number) * 1
|
||||
}
|
||||
history={hashtag.history
|
||||
.map((day) => (day.uses as unknown as number) * 1)
|
||||
.reverse()}
|
||||
/>
|
||||
);
|
||||
|
||||
export interface HashtagProps {
|
||||
className?: string;
|
||||
description?: React.ReactNode;
|
||||
|
@ -6,6 +6,7 @@ import classNames from 'classnames';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { NavLink, withRouter } from 'react-router-dom';
|
||||
|
||||
import { isFulfilled, isRejected } from '@reduxjs/toolkit';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
@ -215,8 +216,20 @@ class Header extends ImmutablePureComponent {
|
||||
|
||||
const link = e.currentTarget;
|
||||
|
||||
onOpenURL(link.href, history, () => {
|
||||
window.location = link.href;
|
||||
onOpenURL(link.href).then((result) => {
|
||||
if (isFulfilled(result)) {
|
||||
if (result.payload.accounts[0]) {
|
||||
history.push(`/@${result.payload.accounts[0].acct}`);
|
||||
} else if (result.payload.statuses[0]) {
|
||||
history.push(`/@${result.payload.statuses[0].account.acct}/${result.payload.statuses[0].id}`);
|
||||
} else {
|
||||
window.location = link.href;
|
||||
}
|
||||
} else if (isRejected(result)) {
|
||||
window.location = link.href;
|
||||
}
|
||||
}).catch(() => {
|
||||
// Nothing
|
||||
});
|
||||
}
|
||||
};
|
||||
|
@ -144,8 +144,8 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
}));
|
||||
},
|
||||
|
||||
onOpenURL (url, routerHistory, onFailure) {
|
||||
dispatch(openURL(url, routerHistory, onFailure));
|
||||
onOpenURL (url) {
|
||||
return dispatch(openURL({ url }));
|
||||
},
|
||||
|
||||
});
|
||||
|
@ -1,402 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage, FormattedList } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
import { domain, searchEnabled } from 'mastodon/initial_state';
|
||||
import { HASHTAG_REGEX } from 'mastodon/utils/hashtags';
|
||||
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
|
||||
placeholderSignedIn: { id: 'search.search_or_paste', defaultMessage: 'Search or paste URL' },
|
||||
});
|
||||
|
||||
const labelForRecentSearch = search => {
|
||||
switch(search.get('type')) {
|
||||
case 'account':
|
||||
return `@${search.get('q')}`;
|
||||
case 'hashtag':
|
||||
return `#${search.get('q')}`;
|
||||
default:
|
||||
return search.get('q');
|
||||
}
|
||||
};
|
||||
|
||||
class Search extends PureComponent {
|
||||
static propTypes = {
|
||||
identity: identityContextPropShape,
|
||||
value: PropTypes.string.isRequired,
|
||||
recent: ImmutablePropTypes.orderedSet,
|
||||
submitted: PropTypes.bool,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
onOpenURL: PropTypes.func.isRequired,
|
||||
onClickSearchResult: PropTypes.func.isRequired,
|
||||
onForgetSearchResult: PropTypes.func.isRequired,
|
||||
onClear: PropTypes.func.isRequired,
|
||||
onShow: PropTypes.func.isRequired,
|
||||
openInRoute: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
singleColumn: PropTypes.bool,
|
||||
...WithRouterPropTypes,
|
||||
};
|
||||
|
||||
state = {
|
||||
expanded: false,
|
||||
selectedOption: -1,
|
||||
options: [],
|
||||
};
|
||||
|
||||
defaultOptions = [
|
||||
{ key: 'prompt-has', label: <><mark>has:</mark> <FormattedList type='disjunction' value={['media', 'poll', 'embed']} /></>, action: e => { e.preventDefault(); this._insertText('has:'); } },
|
||||
{ key: 'prompt-is', label: <><mark>is:</mark> <FormattedList type='disjunction' value={['reply', 'sensitive']} /></>, action: e => { e.preventDefault(); this._insertText('is:'); } },
|
||||
{ key: 'prompt-language', label: <><mark>language:</mark> <FormattedMessage id='search_popout.language_code' defaultMessage='ISO language code' /></>, action: e => { e.preventDefault(); this._insertText('language:'); } },
|
||||
{ key: 'prompt-from', label: <><mark>from:</mark> <FormattedMessage id='search_popout.user' defaultMessage='user' /></>, action: e => { e.preventDefault(); this._insertText('from:'); } },
|
||||
{ key: 'prompt-before', label: <><mark>before:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('before:'); } },
|
||||
{ key: 'prompt-during', label: <><mark>during:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('during:'); } },
|
||||
{ key: 'prompt-after', label: <><mark>after:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('after:'); } },
|
||||
{ key: 'prompt-in', label: <><mark>in:</mark> <FormattedList type='disjunction' value={['all', 'library', 'public']} /></>, action: e => { e.preventDefault(); this._insertText('in:'); } }
|
||||
];
|
||||
|
||||
setRef = c => {
|
||||
this.searchForm = c;
|
||||
};
|
||||
|
||||
handleChange = ({ target }) => {
|
||||
const { onChange } = this.props;
|
||||
|
||||
onChange(target.value);
|
||||
|
||||
this._calculateOptions(target.value);
|
||||
};
|
||||
|
||||
handleClear = e => {
|
||||
const { value, submitted, onClear } = this.props;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
if (value.length > 0 || submitted) {
|
||||
onClear();
|
||||
this.setState({ options: [], selectedOption: -1 });
|
||||
}
|
||||
};
|
||||
|
||||
handleKeyDown = (e) => {
|
||||
const { selectedOption } = this.state;
|
||||
const options = searchEnabled ? this._getOptions().concat(this.defaultOptions) : this._getOptions();
|
||||
|
||||
switch(e.key) {
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
this._unfocus();
|
||||
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
|
||||
if (options.length > 0) {
|
||||
this.setState({ selectedOption: Math.min(selectedOption + 1, options.length - 1) });
|
||||
}
|
||||
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
|
||||
if (options.length > 0) {
|
||||
this.setState({ selectedOption: Math.max(selectedOption - 1, -1) });
|
||||
}
|
||||
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
|
||||
if (selectedOption === -1) {
|
||||
this._submit();
|
||||
} else if (options.length > 0) {
|
||||
options[selectedOption].action(e);
|
||||
}
|
||||
|
||||
break;
|
||||
case 'Delete':
|
||||
if (selectedOption > -1 && options.length > 0) {
|
||||
const search = options[selectedOption];
|
||||
|
||||
if (typeof search.forget === 'function') {
|
||||
e.preventDefault();
|
||||
search.forget(e);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
handleFocus = () => {
|
||||
const { onShow, singleColumn } = this.props;
|
||||
|
||||
this.setState({ expanded: true, selectedOption: -1 });
|
||||
onShow();
|
||||
|
||||
if (this.searchForm && !singleColumn) {
|
||||
const { left, right } = this.searchForm.getBoundingClientRect();
|
||||
|
||||
if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) {
|
||||
this.searchForm.scrollIntoView();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleBlur = () => {
|
||||
this.setState({ expanded: false, selectedOption: -1 });
|
||||
};
|
||||
|
||||
handleHashtagClick = () => {
|
||||
const { value, onClickSearchResult, history } = this.props;
|
||||
|
||||
const query = value.trim().replace(/^#/, '');
|
||||
|
||||
history.push(`/tags/${query}`);
|
||||
onClickSearchResult(query, 'hashtag');
|
||||
this._unfocus();
|
||||
};
|
||||
|
||||
handleAccountClick = () => {
|
||||
const { value, onClickSearchResult, history } = this.props;
|
||||
|
||||
const query = value.trim().replace(/^@/, '');
|
||||
|
||||
history.push(`/@${query}`);
|
||||
onClickSearchResult(query, 'account');
|
||||
this._unfocus();
|
||||
};
|
||||
|
||||
handleURLClick = () => {
|
||||
const { value, onOpenURL, history } = this.props;
|
||||
|
||||
onOpenURL(value, history);
|
||||
this._unfocus();
|
||||
};
|
||||
|
||||
handleStatusSearch = () => {
|
||||
this._submit('statuses');
|
||||
};
|
||||
|
||||
handleAccountSearch = () => {
|
||||
this._submit('accounts');
|
||||
};
|
||||
|
||||
handleRecentSearchClick = search => {
|
||||
const { onChange, history } = this.props;
|
||||
|
||||
if (search.get('type') === 'account') {
|
||||
history.push(`/@${search.get('q')}`);
|
||||
} else if (search.get('type') === 'hashtag') {
|
||||
history.push(`/tags/${search.get('q')}`);
|
||||
} else {
|
||||
onChange(search.get('q'));
|
||||
this._submit(search.get('type'));
|
||||
}
|
||||
|
||||
this._unfocus();
|
||||
};
|
||||
|
||||
handleForgetRecentSearchClick = search => {
|
||||
const { onForgetSearchResult } = this.props;
|
||||
|
||||
onForgetSearchResult(search.get('q'));
|
||||
};
|
||||
|
||||
_unfocus () {
|
||||
document.querySelector('.ui').parentElement.focus();
|
||||
}
|
||||
|
||||
_insertText (text) {
|
||||
const { value, onChange } = this.props;
|
||||
|
||||
if (value === '') {
|
||||
onChange(text);
|
||||
} else if (value[value.length - 1] === ' ') {
|
||||
onChange(`${value}${text}`);
|
||||
} else {
|
||||
onChange(`${value} ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
_submit (type) {
|
||||
const { onSubmit, openInRoute, value, onClickSearchResult, history } = this.props;
|
||||
|
||||
onSubmit(type);
|
||||
|
||||
if (value) {
|
||||
onClickSearchResult(value, type);
|
||||
}
|
||||
|
||||
if (openInRoute) {
|
||||
history.push('/search');
|
||||
}
|
||||
|
||||
this._unfocus();
|
||||
}
|
||||
|
||||
_getOptions () {
|
||||
const { options } = this.state;
|
||||
|
||||
if (options.length > 0) {
|
||||
return options;
|
||||
}
|
||||
|
||||
const { recent } = this.props;
|
||||
|
||||
return recent.toArray().map(search => ({
|
||||
key: `${search.get('type')}/${search.get('q')}`,
|
||||
|
||||
label: labelForRecentSearch(search),
|
||||
|
||||
action: () => this.handleRecentSearchClick(search),
|
||||
|
||||
forget: e => {
|
||||
e.stopPropagation();
|
||||
this.handleForgetRecentSearchClick(search);
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
_calculateOptions (value) {
|
||||
const { signedIn } = this.props.identity;
|
||||
const trimmedValue = value.trim();
|
||||
const options = [];
|
||||
|
||||
if (trimmedValue.length > 0) {
|
||||
const couldBeURL = trimmedValue.startsWith('https://') && !trimmedValue.includes(' ');
|
||||
|
||||
if (couldBeURL) {
|
||||
options.push({ key: 'open-url', label: <FormattedMessage id='search.quick_action.open_url' defaultMessage='Open URL in Mastodon' />, action: this.handleURLClick });
|
||||
}
|
||||
|
||||
const couldBeHashtag = (trimmedValue.startsWith('#') && trimmedValue.length > 1) || trimmedValue.match(HASHTAG_REGEX);
|
||||
|
||||
if (couldBeHashtag) {
|
||||
options.push({ key: 'go-to-hashtag', label: <FormattedMessage id='search.quick_action.go_to_hashtag' defaultMessage='Go to hashtag {x}' values={{ x: <mark>#{trimmedValue.replace(/^#/, '')}</mark> }} />, action: this.handleHashtagClick });
|
||||
}
|
||||
|
||||
const couldBeUsername = trimmedValue.match(/^@?[a-z0-9_-]+(@[^\s]+)?$/i);
|
||||
|
||||
if (couldBeUsername) {
|
||||
options.push({ key: 'go-to-account', label: <FormattedMessage id='search.quick_action.go_to_account' defaultMessage='Go to profile {x}' values={{ x: <mark>@{trimmedValue.replace(/^@/, '')}</mark> }} />, action: this.handleAccountClick });
|
||||
}
|
||||
|
||||
const couldBeStatusSearch = searchEnabled;
|
||||
|
||||
if (couldBeStatusSearch && signedIn) {
|
||||
options.push({ key: 'status-search', label: <FormattedMessage id='search.quick_action.status_search' defaultMessage='Posts matching {x}' values={{ x: <mark>{trimmedValue}</mark> }} />, action: this.handleStatusSearch });
|
||||
}
|
||||
|
||||
const couldBeUserSearch = true;
|
||||
|
||||
if (couldBeUserSearch) {
|
||||
options.push({ key: 'account-search', label: <FormattedMessage id='search.quick_action.account_search' defaultMessage='Profiles matching {x}' values={{ x: <mark>{trimmedValue}</mark> }} />, action: this.handleAccountSearch });
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({ options });
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl, value, submitted, recent } = this.props;
|
||||
const { expanded, options, selectedOption } = this.state;
|
||||
const { signedIn } = this.props.identity;
|
||||
|
||||
const hasValue = value.length > 0 || submitted;
|
||||
|
||||
return (
|
||||
<div className={classNames('search', { active: expanded })}>
|
||||
<input
|
||||
ref={this.setRef}
|
||||
className='search__input'
|
||||
type='text'
|
||||
placeholder={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
|
||||
aria-label={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
|
||||
value={value}
|
||||
onChange={this.handleChange}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onFocus={this.handleFocus}
|
||||
onBlur={this.handleBlur}
|
||||
/>
|
||||
|
||||
<div role='button' tabIndex={0} className='search__icon' onClick={this.handleClear}>
|
||||
<Icon id='search' icon={SearchIcon} className={hasValue ? '' : 'active'} />
|
||||
<Icon id='times-circle' icon={CancelIcon} className={hasValue ? 'active' : ''} aria-label={intl.formatMessage(messages.placeholder)} />
|
||||
</div>
|
||||
|
||||
<div className='search__popout'>
|
||||
{options.length === 0 && (
|
||||
<>
|
||||
<h4><FormattedMessage id='search_popout.recent' defaultMessage='Recent searches' /></h4>
|
||||
|
||||
<div className='search__popout__menu'>
|
||||
{recent.size > 0 ? this._getOptions().map(({ label, key, action, forget }, i) => (
|
||||
<button key={key} onMouseDown={action} className={classNames('search__popout__menu__item search__popout__menu__item--flex', { selected: selectedOption === i })}>
|
||||
<span>{label}</span>
|
||||
<button className='icon-button' onMouseDown={forget}><Icon id='times' icon={CloseIcon} /></button>
|
||||
</button>
|
||||
)) : (
|
||||
<div className='search__popout__menu__message'>
|
||||
<FormattedMessage id='search.no_recent_searches' defaultMessage='No recent searches' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{options.length > 0 && (
|
||||
<>
|
||||
<h4><FormattedMessage id='search_popout.quick_actions' defaultMessage='Quick actions' /></h4>
|
||||
|
||||
<div className='search__popout__menu'>
|
||||
{options.map(({ key, label, action }, i) => (
|
||||
<button key={key} onMouseDown={action} className={classNames('search__popout__menu__item', { selected: selectedOption === i })}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<h4><FormattedMessage id='search_popout.options' defaultMessage='Search options' /></h4>
|
||||
|
||||
{searchEnabled && signedIn ? (
|
||||
<div className='search__popout__menu'>
|
||||
{this.defaultOptions.map(({ key, label, action }, i) => (
|
||||
<button key={key} onMouseDown={action} className={classNames('search__popout__menu__item', { selected: selectedOption === ((options.length || recent.size) + i) })}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className='search__popout__menu__message'>
|
||||
{searchEnabled ? (
|
||||
<FormattedMessage id='search_popout.full_text_search_logged_out_message' defaultMessage='Only available when logged in.' />
|
||||
) : (
|
||||
<FormattedMessage id='search_popout.full_text_search_disabled_message' defaultMessage='Not available on {domain}.' values={{ domain }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default withRouter(withIdentity(injectIntl(Search)));
|
593
app/javascript/mastodon/features/compose/components/search.tsx
Normal file
593
app/javascript/mastodon/features/compose/components/search.tsx
Normal file
@ -0,0 +1,593 @@
|
||||
import { useCallback, useState, useRef } from 'react';
|
||||
|
||||
import {
|
||||
defineMessages,
|
||||
useIntl,
|
||||
FormattedMessage,
|
||||
FormattedList,
|
||||
} from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { isFulfilled } from '@reduxjs/toolkit';
|
||||
|
||||
import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
||||
import {
|
||||
clickSearchResult,
|
||||
forgetSearchResult,
|
||||
openURL,
|
||||
} from 'mastodon/actions/search';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { useIdentity } from 'mastodon/identity_context';
|
||||
import { domain, searchEnabled } from 'mastodon/initial_state';
|
||||
import type { RecentSearch, SearchType } from 'mastodon/models/search';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
import { HASHTAG_REGEX } from 'mastodon/utils/hashtags';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
|
||||
placeholderSignedIn: {
|
||||
id: 'search.search_or_paste',
|
||||
defaultMessage: 'Search or paste URL',
|
||||
},
|
||||
});
|
||||
|
||||
const labelForRecentSearch = (search: RecentSearch) => {
|
||||
switch (search.type) {
|
||||
case 'account':
|
||||
return `@${search.q}`;
|
||||
case 'hashtag':
|
||||
return `#${search.q}`;
|
||||
default:
|
||||
return search.q;
|
||||
}
|
||||
};
|
||||
|
||||
const unfocus = () => {
|
||||
document.querySelector('.ui')?.parentElement?.focus();
|
||||
};
|
||||
|
||||
interface SearchOption {
|
||||
key: string;
|
||||
label: React.ReactNode;
|
||||
action: (e: React.MouseEvent | React.KeyboardEvent) => void;
|
||||
forget?: (e: React.MouseEvent | React.KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
export const Search: React.FC<{
|
||||
singleColumn: boolean;
|
||||
initialValue?: string;
|
||||
}> = ({ singleColumn, initialValue }) => {
|
||||
const intl = useIntl();
|
||||
const recent = useAppSelector((state) => state.search.recent);
|
||||
const { signedIn } = useIdentity();
|
||||
const dispatch = useAppDispatch();
|
||||
const history = useHistory();
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [value, setValue] = useState(initialValue ?? '');
|
||||
const hasValue = value.length > 0;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [selectedOption, setSelectedOption] = useState(-1);
|
||||
const [quickActions, setQuickActions] = useState<SearchOption[]>([]);
|
||||
const searchOptions: SearchOption[] = [];
|
||||
|
||||
if (searchEnabled) {
|
||||
searchOptions.push(
|
||||
{
|
||||
key: 'prompt-has',
|
||||
label: (
|
||||
<>
|
||||
<mark>has:</mark>{' '}
|
||||
<FormattedList
|
||||
type='disjunction'
|
||||
value={['media', 'poll', 'embed']}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
action: (e) => {
|
||||
e.preventDefault();
|
||||
insertText('has:');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'prompt-is',
|
||||
label: (
|
||||
<>
|
||||
<mark>is:</mark>{' '}
|
||||
<FormattedList type='disjunction' value={['reply', 'sensitive']} />
|
||||
</>
|
||||
),
|
||||
action: (e) => {
|
||||
e.preventDefault();
|
||||
insertText('is:');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'prompt-language',
|
||||
label: (
|
||||
<>
|
||||
<mark>language:</mark>{' '}
|
||||
<FormattedMessage
|
||||
id='search_popout.language_code'
|
||||
defaultMessage='ISO language code'
|
||||
/>
|
||||
</>
|
||||
),
|
||||
action: (e) => {
|
||||
e.preventDefault();
|
||||
insertText('language:');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'prompt-from',
|
||||
label: (
|
||||
<>
|
||||
<mark>from:</mark>{' '}
|
||||
<FormattedMessage id='search_popout.user' defaultMessage='user' />
|
||||
</>
|
||||
),
|
||||
action: (e) => {
|
||||
e.preventDefault();
|
||||
insertText('from:');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'prompt-before',
|
||||
label: (
|
||||
<>
|
||||
<mark>before:</mark>{' '}
|
||||
<FormattedMessage
|
||||
id='search_popout.specific_date'
|
||||
defaultMessage='specific date'
|
||||
/>
|
||||
</>
|
||||
),
|
||||
action: (e) => {
|
||||
e.preventDefault();
|
||||
insertText('before:');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'prompt-during',
|
||||
label: (
|
||||
<>
|
||||
<mark>during:</mark>{' '}
|
||||
<FormattedMessage
|
||||
id='search_popout.specific_date'
|
||||
defaultMessage='specific date'
|
||||
/>
|
||||
</>
|
||||
),
|
||||
action: (e) => {
|
||||
e.preventDefault();
|
||||
insertText('during:');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'prompt-after',
|
||||
label: (
|
||||
<>
|
||||
<mark>after:</mark>{' '}
|
||||
<FormattedMessage
|
||||
id='search_popout.specific_date'
|
||||
defaultMessage='specific date'
|
||||
/>
|
||||
</>
|
||||
),
|
||||
action: (e) => {
|
||||
e.preventDefault();
|
||||
insertText('after:');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'prompt-in',
|
||||
label: (
|
||||
<>
|
||||
<mark>in:</mark>{' '}
|
||||
<FormattedList
|
||||
type='disjunction'
|
||||
value={['all', 'library', 'public']}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
action: (e) => {
|
||||
e.preventDefault();
|
||||
insertText('in:');
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const recentOptions: SearchOption[] = recent.map((search) => ({
|
||||
key: `${search.type}/${search.q}`,
|
||||
label: labelForRecentSearch(search),
|
||||
action: () => {
|
||||
setValue(search.q);
|
||||
|
||||
if (search.type === 'account') {
|
||||
history.push(`/@${search.q}`);
|
||||
} else if (search.type === 'hashtag') {
|
||||
history.push(`/tags/${search.q}`);
|
||||
} else {
|
||||
const queryParams = new URLSearchParams({ q: search.q });
|
||||
if (search.type) queryParams.set('type', search.type);
|
||||
history.push({ pathname: '/search', search: queryParams.toString() });
|
||||
}
|
||||
|
||||
unfocus();
|
||||
},
|
||||
forget: (e) => {
|
||||
e.stopPropagation();
|
||||
void dispatch(forgetSearchResult(search.q));
|
||||
},
|
||||
}));
|
||||
|
||||
const navigableOptions = hasValue
|
||||
? quickActions.concat(searchOptions)
|
||||
: recentOptions.concat(quickActions, searchOptions);
|
||||
|
||||
const insertText = (text: string) => {
|
||||
setValue((currentValue) => {
|
||||
if (currentValue === '') {
|
||||
return text;
|
||||
} else if (currentValue.endsWith(' ')) {
|
||||
return `${currentValue}${text}`;
|
||||
} else {
|
||||
return `${currentValue} ${text}`;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const submit = useCallback(
|
||||
(q: string, type?: SearchType) => {
|
||||
void dispatch(clickSearchResult({ q, type }));
|
||||
const queryParams = new URLSearchParams({ q });
|
||||
if (type) queryParams.set('type', type);
|
||||
history.push({ pathname: '/search', search: queryParams.toString() });
|
||||
unfocus();
|
||||
},
|
||||
[dispatch, history],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(value);
|
||||
|
||||
const trimmedValue = value.trim();
|
||||
const newQuickActions = [];
|
||||
|
||||
if (trimmedValue.length > 0) {
|
||||
const couldBeURL =
|
||||
trimmedValue.startsWith('https://') && !trimmedValue.includes(' ');
|
||||
|
||||
if (couldBeURL) {
|
||||
newQuickActions.push({
|
||||
key: 'open-url',
|
||||
label: (
|
||||
<FormattedMessage
|
||||
id='search.quick_action.open_url'
|
||||
defaultMessage='Open URL in Mastodon'
|
||||
/>
|
||||
),
|
||||
action: async () => {
|
||||
const result = await dispatch(openURL({ url: trimmedValue }));
|
||||
|
||||
if (isFulfilled(result)) {
|
||||
if (result.payload.accounts[0]) {
|
||||
history.push(`/@${result.payload.accounts[0].acct}`);
|
||||
} else if (result.payload.statuses[0]) {
|
||||
history.push(
|
||||
`/@${result.payload.statuses[0].account.acct}/${result.payload.statuses[0].id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
unfocus();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const couldBeHashtag =
|
||||
(trimmedValue.startsWith('#') && trimmedValue.length > 1) ||
|
||||
trimmedValue.match(HASHTAG_REGEX);
|
||||
|
||||
if (couldBeHashtag) {
|
||||
newQuickActions.push({
|
||||
key: 'go-to-hashtag',
|
||||
label: (
|
||||
<FormattedMessage
|
||||
id='search.quick_action.go_to_hashtag'
|
||||
defaultMessage='Go to hashtag {x}'
|
||||
values={{ x: <mark>#{trimmedValue.replace(/^#/, '')}</mark> }}
|
||||
/>
|
||||
),
|
||||
action: () => {
|
||||
const query = trimmedValue.replace(/^#/, '');
|
||||
history.push(`/tags/${query}`);
|
||||
void dispatch(clickSearchResult({ q: query, type: 'hashtag' }));
|
||||
unfocus();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const couldBeUsername = /^@?[a-z0-9_-]+(@[^\s]+)?$/i.exec(trimmedValue);
|
||||
|
||||
if (couldBeUsername) {
|
||||
newQuickActions.push({
|
||||
key: 'go-to-account',
|
||||
label: (
|
||||
<FormattedMessage
|
||||
id='search.quick_action.go_to_account'
|
||||
defaultMessage='Go to profile {x}'
|
||||
values={{ x: <mark>@{trimmedValue.replace(/^@/, '')}</mark> }}
|
||||
/>
|
||||
),
|
||||
action: () => {
|
||||
const query = trimmedValue.replace(/^@/, '');
|
||||
history.push(`/@${query}`);
|
||||
void dispatch(clickSearchResult({ q: query, type: 'account' }));
|
||||
unfocus();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const couldBeStatusSearch = searchEnabled;
|
||||
|
||||
if (couldBeStatusSearch && signedIn) {
|
||||
newQuickActions.push({
|
||||
key: 'status-search',
|
||||
label: (
|
||||
<FormattedMessage
|
||||
id='search.quick_action.status_search'
|
||||
defaultMessage='Posts matching {x}'
|
||||
values={{ x: <mark>{trimmedValue}</mark> }}
|
||||
/>
|
||||
),
|
||||
action: () => {
|
||||
submit(trimmedValue, 'statuses');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
newQuickActions.push({
|
||||
key: 'account-search',
|
||||
label: (
|
||||
<FormattedMessage
|
||||
id='search.quick_action.account_search'
|
||||
defaultMessage='Profiles matching {x}'
|
||||
values={{ x: <mark>{trimmedValue}</mark> }}
|
||||
/>
|
||||
),
|
||||
action: () => {
|
||||
submit(trimmedValue, 'accounts');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setQuickActions(newQuickActions);
|
||||
},
|
||||
[dispatch, history, signedIn, setValue, setQuickActions, submit],
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setValue('');
|
||||
setQuickActions([]);
|
||||
setSelectedOption(-1);
|
||||
}, [setValue, setQuickActions, setSelectedOption]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
unfocus();
|
||||
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
|
||||
if (navigableOptions.length > 0) {
|
||||
setSelectedOption(
|
||||
Math.min(selectedOption + 1, navigableOptions.length - 1),
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
|
||||
if (navigableOptions.length > 0) {
|
||||
setSelectedOption(Math.max(selectedOption - 1, -1));
|
||||
}
|
||||
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
|
||||
if (selectedOption === -1) {
|
||||
submit(value);
|
||||
} else if (navigableOptions.length > 0) {
|
||||
navigableOptions[selectedOption]?.action(e);
|
||||
}
|
||||
|
||||
break;
|
||||
case 'Delete':
|
||||
if (selectedOption > -1 && navigableOptions.length > 0) {
|
||||
const search = navigableOptions[selectedOption];
|
||||
|
||||
if (typeof search?.forget === 'function') {
|
||||
e.preventDefault();
|
||||
search.forget(e);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
},
|
||||
[navigableOptions, value, selectedOption, setSelectedOption, submit],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setExpanded(true);
|
||||
setSelectedOption(-1);
|
||||
|
||||
if (searchInputRef.current && !singleColumn) {
|
||||
const { left, right } = searchInputRef.current.getBoundingClientRect();
|
||||
|
||||
if (
|
||||
left < 0 ||
|
||||
right > (window.innerWidth || document.documentElement.clientWidth)
|
||||
) {
|
||||
searchInputRef.current.scrollIntoView();
|
||||
}
|
||||
}
|
||||
}, [setExpanded, setSelectedOption, singleColumn]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setExpanded(false);
|
||||
setSelectedOption(-1);
|
||||
}, [setExpanded, setSelectedOption]);
|
||||
|
||||
return (
|
||||
<form className={classNames('search', { active: expanded })}>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className='search__input'
|
||||
type='text'
|
||||
placeholder={intl.formatMessage(
|
||||
signedIn ? messages.placeholderSignedIn : messages.placeholder,
|
||||
)}
|
||||
aria-label={intl.formatMessage(
|
||||
signedIn ? messages.placeholderSignedIn : messages.placeholder,
|
||||
)}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
|
||||
<button type='button' className='search__icon' onClick={handleClear}>
|
||||
<Icon
|
||||
id='search'
|
||||
icon={SearchIcon}
|
||||
className={hasValue ? '' : 'active'}
|
||||
/>
|
||||
<Icon
|
||||
id='times-circle'
|
||||
icon={CancelIcon}
|
||||
className={hasValue ? 'active' : ''}
|
||||
aria-label={intl.formatMessage(messages.placeholder)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div className='search__popout'>
|
||||
{!hasValue && (
|
||||
<>
|
||||
<h4>
|
||||
<FormattedMessage
|
||||
id='search_popout.recent'
|
||||
defaultMessage='Recent searches'
|
||||
/>
|
||||
</h4>
|
||||
|
||||
<div className='search__popout__menu'>
|
||||
{recentOptions.length > 0 ? (
|
||||
recentOptions.map(({ label, key, action, forget }, i) => (
|
||||
<button
|
||||
key={key}
|
||||
onMouseDown={action}
|
||||
className={classNames(
|
||||
'search__popout__menu__item search__popout__menu__item--flex',
|
||||
{ selected: selectedOption === i },
|
||||
)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<button className='icon-button' onMouseDown={forget}>
|
||||
<Icon id='times' icon={CloseIcon} />
|
||||
</button>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className='search__popout__menu__message'>
|
||||
<FormattedMessage
|
||||
id='search.no_recent_searches'
|
||||
defaultMessage='No recent searches'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{quickActions.length > 0 && (
|
||||
<>
|
||||
<h4>
|
||||
<FormattedMessage
|
||||
id='search_popout.quick_actions'
|
||||
defaultMessage='Quick actions'
|
||||
/>
|
||||
</h4>
|
||||
|
||||
<div className='search__popout__menu'>
|
||||
{quickActions.map(({ key, label, action }, i) => (
|
||||
<button
|
||||
key={key}
|
||||
onMouseDown={action}
|
||||
className={classNames('search__popout__menu__item', {
|
||||
selected: selectedOption === i,
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<h4>
|
||||
<FormattedMessage
|
||||
id='search_popout.options'
|
||||
defaultMessage='Search options'
|
||||
/>
|
||||
</h4>
|
||||
|
||||
{searchEnabled && signedIn ? (
|
||||
<div className='search__popout__menu'>
|
||||
{searchOptions.map(({ key, label, action }, i) => (
|
||||
<button
|
||||
key={key}
|
||||
onMouseDown={action}
|
||||
className={classNames('search__popout__menu__item', {
|
||||
selected:
|
||||
selectedOption ===
|
||||
(quickActions.length || recent.length) + i,
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className='search__popout__menu__message'>
|
||||
{searchEnabled ? (
|
||||
<FormattedMessage
|
||||
id='search_popout.full_text_search_logged_out_message'
|
||||
defaultMessage='Only available when logged in.'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='search_popout.full_text_search_disabled_message'
|
||||
defaultMessage='Not available on {domain}.'
|
||||
values={{ domain }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
@ -1,93 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import FindInPageIcon from '@/material-icons/400-24px/find_in_page.svg?react';
|
||||
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
||||
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
|
||||
import { expandSearch } from 'mastodon/actions/search';
|
||||
import { Account } from 'mastodon/components/account';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { LoadMore } from 'mastodon/components/load_more';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { SearchSection } from 'mastodon/features/explore/components/search_section';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { ImmutableHashtag as Hashtag } from '../../../components/hashtag';
|
||||
import StatusContainer from '../../../containers/status_container';
|
||||
|
||||
const INITIAL_PAGE_LIMIT = 10;
|
||||
|
||||
const withoutLastResult = list => {
|
||||
if (list.size > INITIAL_PAGE_LIMIT && list.size % INITIAL_PAGE_LIMIT === 1) {
|
||||
return list.skipLast(1);
|
||||
} else {
|
||||
return list;
|
||||
}
|
||||
};
|
||||
|
||||
export const SearchResults = () => {
|
||||
const results = useAppSelector((state) => state.getIn(['search', 'results']));
|
||||
const isLoading = useAppSelector((state) => state.getIn(['search', 'isLoading']));
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleLoadMoreAccounts = useCallback(() => {
|
||||
dispatch(expandSearch('accounts'));
|
||||
}, [dispatch]);
|
||||
|
||||
const handleLoadMoreStatuses = useCallback(() => {
|
||||
dispatch(expandSearch('statuses'));
|
||||
}, [dispatch]);
|
||||
|
||||
const handleLoadMoreHashtags = useCallback(() => {
|
||||
dispatch(expandSearch('hashtags'));
|
||||
}, [dispatch]);
|
||||
|
||||
let accounts, statuses, hashtags;
|
||||
|
||||
if (results.get('accounts') && results.get('accounts').size > 0) {
|
||||
accounts = (
|
||||
<SearchSection title={<><Icon id='users' icon={PeopleIcon} /><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></>}>
|
||||
{withoutLastResult(results.get('accounts')).map(accountId => <Account key={accountId} id={accountId} />)}
|
||||
{(results.get('accounts').size > INITIAL_PAGE_LIMIT && results.get('accounts').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={handleLoadMoreAccounts} />}
|
||||
</SearchSection>
|
||||
);
|
||||
}
|
||||
|
||||
if (results.get('hashtags') && results.get('hashtags').size > 0) {
|
||||
hashtags = (
|
||||
<SearchSection title={<><Icon id='hashtag' icon={TagIcon} /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></>}>
|
||||
{withoutLastResult(results.get('hashtags')).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
|
||||
{(results.get('hashtags').size > INITIAL_PAGE_LIMIT && results.get('hashtags').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={handleLoadMoreHashtags} />}
|
||||
</SearchSection>
|
||||
);
|
||||
}
|
||||
|
||||
if (results.get('statuses') && results.get('statuses').size > 0) {
|
||||
statuses = (
|
||||
<SearchSection title={<><Icon id='quote-right' icon={FindInPageIcon} /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></>}>
|
||||
{withoutLastResult(results.get('statuses')).map(statusId => <StatusContainer key={statusId} id={statusId} />)}
|
||||
{(results.get('statuses').size > INITIAL_PAGE_LIMIT && results.get('statuses').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={handleLoadMoreStatuses} />}
|
||||
</SearchSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='search-results'>
|
||||
{!accounts && !hashtags && !statuses && (
|
||||
isLoading ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
<div className='empty-column-indicator'>
|
||||
<FormattedMessage id='search_results.nothing_found' defaultMessage='Could not find anything for these search terms' />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{accounts}
|
||||
{hashtags}
|
||||
{statuses}
|
||||
</div>
|
||||
);
|
||||
|
||||
};
|
@ -1,59 +0,0 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
changeSearch,
|
||||
clearSearch,
|
||||
submitSearch,
|
||||
showSearch,
|
||||
openURL,
|
||||
clickSearchResult,
|
||||
forgetSearchResult,
|
||||
} from 'mastodon/actions/search';
|
||||
|
||||
import Search from '../components/search';
|
||||
|
||||
const getRecentSearches = createSelector(
|
||||
state => state.getIn(['search', 'recent']),
|
||||
recent => recent.reverse(),
|
||||
);
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
value: state.getIn(['search', 'value']),
|
||||
submitted: state.getIn(['search', 'submitted']),
|
||||
recent: getRecentSearches(state),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
|
||||
onChange (value) {
|
||||
dispatch(changeSearch(value));
|
||||
},
|
||||
|
||||
onClear () {
|
||||
dispatch(clearSearch());
|
||||
},
|
||||
|
||||
onSubmit (type) {
|
||||
dispatch(submitSearch(type));
|
||||
},
|
||||
|
||||
onShow () {
|
||||
dispatch(showSearch());
|
||||
},
|
||||
|
||||
onOpenURL (q, routerHistory) {
|
||||
dispatch(openURL(q, routerHistory));
|
||||
},
|
||||
|
||||
onClickSearchResult (q, type) {
|
||||
dispatch(clickSearchResult(q, type));
|
||||
},
|
||||
|
||||
onForgetSearchResult (q) {
|
||||
dispatch(forgetSearchResult(q));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Search);
|
@ -9,8 +9,6 @@ import { Link } from 'react-router-dom';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import spring from 'react-motion/lib/spring';
|
||||
|
||||
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
||||
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
||||
import LogoutIcon from '@/material-icons/400-24px/logout.svg?react';
|
||||
@ -26,11 +24,9 @@ import elephantUIPlane from '../../../images/elephant_ui_plane.svg';
|
||||
import { changeComposing, mountCompose, unmountCompose } from '../../actions/compose';
|
||||
import { mascot } from '../../initial_state';
|
||||
import { isMobile } from '../../is_mobile';
|
||||
import Motion from '../ui/util/optional_motion';
|
||||
|
||||
import { SearchResults } from './components/search_results';
|
||||
import { Search } from './components/search';
|
||||
import ComposeFormContainer from './containers/compose_form_container';
|
||||
import SearchContainer from './containers/search_container';
|
||||
|
||||
const messages = defineMessages({
|
||||
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
|
||||
@ -43,9 +39,8 @@ const messages = defineMessages({
|
||||
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' },
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, ownProps) => ({
|
||||
const mapStateToProps = (state) => ({
|
||||
columns: state.getIn(['settings', 'columns']),
|
||||
showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : false,
|
||||
});
|
||||
|
||||
class Compose extends PureComponent {
|
||||
@ -54,7 +49,6 @@ class Compose extends PureComponent {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
columns: ImmutablePropTypes.list.isRequired,
|
||||
multiColumn: PropTypes.bool,
|
||||
showSearch: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
@ -88,7 +82,7 @@ class Compose extends PureComponent {
|
||||
};
|
||||
|
||||
render () {
|
||||
const { multiColumn, showSearch, intl } = this.props;
|
||||
const { multiColumn, intl } = this.props;
|
||||
|
||||
if (multiColumn) {
|
||||
const { columns } = this.props;
|
||||
@ -113,7 +107,7 @@ class Compose extends PureComponent {
|
||||
<a href='/auth/sign_out' className='drawer__tab' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)} onClick={this.handleLogoutClick}><Icon id='sign-out' icon={LogoutIcon} /></a>
|
||||
</nav>
|
||||
|
||||
{multiColumn && <SearchContainer /> }
|
||||
{multiColumn && <Search /> }
|
||||
|
||||
<div className='drawer__pager'>
|
||||
<div className='drawer__inner' onFocus={this.onFocus}>
|
||||
@ -123,14 +117,6 @@ class Compose extends PureComponent {
|
||||
<img alt='' draggable='false' src={mascot || elephantUIPlane} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
|
||||
{({ x }) => (
|
||||
<div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
|
||||
<SearchResults />
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,20 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export const SearchSection = ({ title, onClickMore, children }) => (
|
||||
<div className='search-results__section'>
|
||||
<div className='search-results__section__header'>
|
||||
<h3>{title}</h3>
|
||||
{onClickMore && <button onClick={onClickMore}><FormattedMessage id='search_results.see_all' defaultMessage='See all' /></button>}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
SearchSection.propTypes = {
|
||||
title: PropTypes.node.isRequired,
|
||||
onClickMore: PropTypes.func,
|
||||
children: PropTypes.children,
|
||||
};
|
@ -1,114 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { NavLink, Switch, Route } from 'react-router-dom';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
|
||||
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
||||
import Column from 'mastodon/components/column';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import Search from 'mastodon/features/compose/containers/search_container';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
import { trendsEnabled } from 'mastodon/initial_state';
|
||||
|
||||
import Links from './links';
|
||||
import SearchResults from './results';
|
||||
import Statuses from './statuses';
|
||||
import Suggestions from './suggestions';
|
||||
import Tags from './tags';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'explore.title', defaultMessage: 'Explore' },
|
||||
searchResults: { id: 'explore.search_results', defaultMessage: 'Search results' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
layout: state.getIn(['meta', 'layout']),
|
||||
isSearching: state.getIn(['search', 'submitted']) || !trendsEnabled,
|
||||
});
|
||||
|
||||
class Explore extends PureComponent {
|
||||
static propTypes = {
|
||||
identity: identityContextPropShape,
|
||||
intl: PropTypes.object.isRequired,
|
||||
multiColumn: PropTypes.bool,
|
||||
isSearching: PropTypes.bool,
|
||||
};
|
||||
|
||||
handleHeaderClick = () => {
|
||||
this.column.scrollTop();
|
||||
};
|
||||
|
||||
setRef = c => {
|
||||
this.column = c;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { intl, multiColumn, isSearching } = this.props;
|
||||
const { signedIn } = this.props.identity;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
||||
<ColumnHeader
|
||||
icon={isSearching ? 'search' : 'explore'}
|
||||
iconComponent={isSearching ? SearchIcon : ExploreIcon}
|
||||
title={intl.formatMessage(isSearching ? messages.searchResults : messages.title)}
|
||||
onClick={this.handleHeaderClick}
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
|
||||
<div className='explore__search-header'>
|
||||
<Search />
|
||||
</div>
|
||||
|
||||
{isSearching ? (
|
||||
<SearchResults />
|
||||
) : (
|
||||
<>
|
||||
<div className='account__section-headline'>
|
||||
<NavLink exact to='/explore'>
|
||||
<FormattedMessage tagName='div' id='explore.trending_statuses' defaultMessage='Posts' />
|
||||
</NavLink>
|
||||
|
||||
<NavLink exact to='/explore/tags'>
|
||||
<FormattedMessage tagName='div' id='explore.trending_tags' defaultMessage='Hashtags' />
|
||||
</NavLink>
|
||||
|
||||
{signedIn && (
|
||||
<NavLink exact to='/explore/suggestions'>
|
||||
<FormattedMessage tagName='div' id='explore.suggested_follows' defaultMessage='People' />
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
<NavLink exact to='/explore/links'>
|
||||
<FormattedMessage tagName='div' id='explore.trending_links' defaultMessage='News' />
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<Switch>
|
||||
<Route path='/explore/tags' component={Tags} />
|
||||
<Route path='/explore/links' component={Links} />
|
||||
<Route path='/explore/suggestions' component={Suggestions} />
|
||||
<Route exact path={['/explore', '/explore/posts', '/search']}>
|
||||
<Statuses multiColumn={multiColumn} />
|
||||
</Route>
|
||||
</Switch>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title)}</title>
|
||||
<meta name='robots' content={isSearching ? 'noindex' : 'all'} />
|
||||
</Helmet>
|
||||
</>
|
||||
)}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default withIdentity(connect(mapStateToProps)(injectIntl(Explore)));
|
105
app/javascript/mastodon/features/explore/index.tsx
Normal file
105
app/javascript/mastodon/features/explore/index.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { NavLink, Switch, Route } from 'react-router-dom';
|
||||
|
||||
import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import type { ColumnRef } from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
import { Search } from 'mastodon/features/compose/components/search';
|
||||
import { useIdentity } from 'mastodon/identity_context';
|
||||
|
||||
import Links from './links';
|
||||
import Statuses from './statuses';
|
||||
import Suggestions from './suggestions';
|
||||
import Tags from './tags';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'explore.title', defaultMessage: 'Explore' },
|
||||
});
|
||||
|
||||
const Explore: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
const { signedIn } = useIdentity();
|
||||
const intl = useIntl();
|
||||
const columnRef = useRef<ColumnRef>(null);
|
||||
|
||||
const handleHeaderClick = useCallback(() => {
|
||||
columnRef.current?.scrollTop();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
ref={columnRef}
|
||||
label={intl.formatMessage(messages.title)}
|
||||
>
|
||||
<ColumnHeader
|
||||
icon={'explore'}
|
||||
iconComponent={ExploreIcon}
|
||||
title={intl.formatMessage(messages.title)}
|
||||
onClick={handleHeaderClick}
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
|
||||
<div className='explore__search-header'>
|
||||
<Search singleColumn />
|
||||
</div>
|
||||
|
||||
<div className='account__section-headline'>
|
||||
<NavLink exact to='/explore'>
|
||||
<FormattedMessage
|
||||
tagName='div'
|
||||
id='explore.trending_statuses'
|
||||
defaultMessage='Posts'
|
||||
/>
|
||||
</NavLink>
|
||||
|
||||
<NavLink exact to='/explore/tags'>
|
||||
<FormattedMessage
|
||||
tagName='div'
|
||||
id='explore.trending_tags'
|
||||
defaultMessage='Hashtags'
|
||||
/>
|
||||
</NavLink>
|
||||
|
||||
{signedIn && (
|
||||
<NavLink exact to='/explore/suggestions'>
|
||||
<FormattedMessage
|
||||
tagName='div'
|
||||
id='explore.suggested_follows'
|
||||
defaultMessage='People'
|
||||
/>
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
<NavLink exact to='/explore/links'>
|
||||
<FormattedMessage
|
||||
tagName='div'
|
||||
id='explore.trending_links'
|
||||
defaultMessage='News'
|
||||
/>
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<Switch>
|
||||
<Route path='/explore/tags' component={Tags} />
|
||||
<Route path='/explore/links' component={Links} />
|
||||
<Route path='/explore/suggestions' component={Suggestions} />
|
||||
<Route exact path={['/explore', '/explore/posts']}>
|
||||
<Statuses multiColumn={multiColumn} />
|
||||
</Route>
|
||||
</Switch>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title)}</title>
|
||||
<meta name='robots' content='all' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default Explore;
|
@ -1,232 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import FindInPageIcon from '@/material-icons/400-24px/find_in_page.svg?react';
|
||||
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
||||
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
|
||||
import { submitSearch, expandSearch } from 'mastodon/actions/search';
|
||||
import { Account } from 'mastodon/components/account';
|
||||
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||
import Status from 'mastodon/containers/status_container';
|
||||
|
||||
import { SearchSection } from './components/search_section';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'search_results.title', defaultMessage: 'Search for {q}' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
isLoading: state.getIn(['search', 'isLoading']),
|
||||
results: state.getIn(['search', 'results']),
|
||||
q: state.getIn(['search', 'searchTerm']),
|
||||
submittedType: state.getIn(['search', 'type']),
|
||||
});
|
||||
|
||||
const INITIAL_PAGE_LIMIT = 10;
|
||||
const INITIAL_DISPLAY = 4;
|
||||
|
||||
const hidePeek = list => {
|
||||
if (list.size > INITIAL_PAGE_LIMIT && list.size % INITIAL_PAGE_LIMIT === 1) {
|
||||
return list.skipLast(1);
|
||||
} else {
|
||||
return list;
|
||||
}
|
||||
};
|
||||
|
||||
const renderAccounts = accounts => hidePeek(accounts).map(id => (
|
||||
<Account key={id} id={id} />
|
||||
));
|
||||
|
||||
const renderHashtags = hashtags => hidePeek(hashtags).map(hashtag => (
|
||||
<Hashtag key={hashtag.get('name')} hashtag={hashtag} />
|
||||
));
|
||||
|
||||
const renderStatuses = statuses => hidePeek(statuses).map(id => (
|
||||
<Status key={id} id={id} />
|
||||
));
|
||||
|
||||
class Results extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
results: ImmutablePropTypes.contains({
|
||||
accounts: ImmutablePropTypes.orderedSet,
|
||||
statuses: ImmutablePropTypes.orderedSet,
|
||||
hashtags: ImmutablePropTypes.orderedSet,
|
||||
}),
|
||||
isLoading: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
q: PropTypes.string,
|
||||
intl: PropTypes.object,
|
||||
submittedType: PropTypes.oneOf(['accounts', 'statuses', 'hashtags']),
|
||||
};
|
||||
|
||||
state = {
|
||||
type: this.props.submittedType || 'all',
|
||||
};
|
||||
|
||||
static getDerivedStateFromProps(props, state) {
|
||||
if (props.submittedType !== state.type) {
|
||||
return {
|
||||
type: props.submittedType || 'all',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
handleSelectAll = () => {
|
||||
const { submittedType, dispatch } = this.props;
|
||||
|
||||
// If we originally searched for a specific type, we need to resubmit
|
||||
// the query to get all types of results
|
||||
if (submittedType) {
|
||||
dispatch(submitSearch());
|
||||
}
|
||||
|
||||
this.setState({ type: 'all' });
|
||||
};
|
||||
|
||||
handleSelectAccounts = () => {
|
||||
const { submittedType, dispatch } = this.props;
|
||||
|
||||
// If we originally searched for something else (but not everything),
|
||||
// we need to resubmit the query for this specific type
|
||||
if (submittedType !== 'accounts') {
|
||||
dispatch(submitSearch('accounts'));
|
||||
}
|
||||
|
||||
this.setState({ type: 'accounts' });
|
||||
};
|
||||
|
||||
handleSelectHashtags = () => {
|
||||
const { submittedType, dispatch } = this.props;
|
||||
|
||||
// If we originally searched for something else (but not everything),
|
||||
// we need to resubmit the query for this specific type
|
||||
if (submittedType !== 'hashtags') {
|
||||
dispatch(submitSearch('hashtags'));
|
||||
}
|
||||
|
||||
this.setState({ type: 'hashtags' });
|
||||
};
|
||||
|
||||
handleSelectStatuses = () => {
|
||||
const { submittedType, dispatch } = this.props;
|
||||
|
||||
// If we originally searched for something else (but not everything),
|
||||
// we need to resubmit the query for this specific type
|
||||
if (submittedType !== 'statuses') {
|
||||
dispatch(submitSearch('statuses'));
|
||||
}
|
||||
|
||||
this.setState({ type: 'statuses' });
|
||||
};
|
||||
|
||||
handleLoadMoreAccounts = () => this._loadMore('accounts');
|
||||
handleLoadMoreStatuses = () => this._loadMore('statuses');
|
||||
handleLoadMoreHashtags = () => this._loadMore('hashtags');
|
||||
|
||||
_loadMore (type) {
|
||||
const { dispatch } = this.props;
|
||||
dispatch(expandSearch(type));
|
||||
}
|
||||
|
||||
handleLoadMore = () => {
|
||||
const { type } = this.state;
|
||||
|
||||
if (type !== 'all') {
|
||||
this._loadMore(type);
|
||||
}
|
||||
};
|
||||
|
||||
render () {
|
||||
const { intl, isLoading, q, results } = this.props;
|
||||
const { type } = this.state;
|
||||
|
||||
// We request 1 more result than we display so we can tell if there'd be a next page
|
||||
const hasMore = type !== 'all' ? results.get(type, ImmutableList()).size > INITIAL_PAGE_LIMIT && results.get(type).size % INITIAL_PAGE_LIMIT === 1 : false;
|
||||
|
||||
let filteredResults;
|
||||
|
||||
const accounts = results.get('accounts', ImmutableList());
|
||||
const hashtags = results.get('hashtags', ImmutableList());
|
||||
const statuses = results.get('statuses', ImmutableList());
|
||||
|
||||
switch(type) {
|
||||
case 'all':
|
||||
filteredResults = (accounts.size + hashtags.size + statuses.size) > 0 ? (
|
||||
<>
|
||||
{accounts.size > 0 && (
|
||||
<SearchSection key='accounts' title={<><Icon id='users' icon={PeopleIcon} /><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></>} onClickMore={this.handleLoadMoreAccounts}>
|
||||
{accounts.take(INITIAL_DISPLAY).map(id => <Account key={id} id={id} />)}
|
||||
</SearchSection>
|
||||
)}
|
||||
|
||||
{hashtags.size > 0 && (
|
||||
<SearchSection key='hashtags' title={<><Icon id='hashtag' icon={TagIcon} /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></>} onClickMore={this.handleLoadMoreHashtags}>
|
||||
{hashtags.take(INITIAL_DISPLAY).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
|
||||
</SearchSection>
|
||||
)}
|
||||
|
||||
{statuses.size > 0 && (
|
||||
<SearchSection key='statuses' title={<><Icon id='quote-right' icon={FindInPageIcon} /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></>} onClickMore={this.handleLoadMoreStatuses}>
|
||||
{statuses.take(INITIAL_DISPLAY).map(id => <Status key={id} id={id} />)}
|
||||
</SearchSection>
|
||||
)}
|
||||
</>
|
||||
) : [];
|
||||
break;
|
||||
case 'accounts':
|
||||
filteredResults = renderAccounts(accounts);
|
||||
break;
|
||||
case 'hashtags':
|
||||
filteredResults = renderHashtags(hashtags);
|
||||
break;
|
||||
case 'statuses':
|
||||
filteredResults = renderStatuses(statuses);
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='account__section-headline'>
|
||||
<button onClick={this.handleSelectAll} className={type === 'all' ? 'active' : undefined}><FormattedMessage id='search_results.all' defaultMessage='All' /></button>
|
||||
<button onClick={this.handleSelectAccounts} className={type === 'accounts' ? 'active' : undefined}><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></button>
|
||||
<button onClick={this.handleSelectHashtags} className={type === 'hashtags' ? 'active' : undefined}><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></button>
|
||||
<button onClick={this.handleSelectStatuses} className={type === 'statuses' ? 'active' : undefined}><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></button>
|
||||
</div>
|
||||
|
||||
<div className='explore__search-results' data-nosnippet>
|
||||
<ScrollableList
|
||||
scrollKey='search-results'
|
||||
isLoading={isLoading}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
hasMore={hasMore}
|
||||
emptyMessage={<FormattedMessage id='search_results.nothing_found' defaultMessage='Could not find anything for these search terms' />}
|
||||
bindToDocument
|
||||
>
|
||||
{filteredResults}
|
||||
</ScrollableList>
|
||||
</div>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title, { q })}</title>
|
||||
</Helmet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(injectIntl(Results));
|
@ -0,0 +1,23 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export const SearchSection: React.FC<{
|
||||
title: React.ReactNode;
|
||||
onClickMore?: () => void;
|
||||
children: React.ReactNode;
|
||||
}> = ({ title, onClickMore, children }) => (
|
||||
<div className='search-results__section'>
|
||||
<div className='search-results__section__header'>
|
||||
<h3>{title}</h3>
|
||||
{onClickMore && (
|
||||
<button onClick={onClickMore}>
|
||||
<FormattedMessage
|
||||
id='search_results.see_all'
|
||||
defaultMessage='See all'
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
304
app/javascript/mastodon/features/search/index.tsx
Normal file
304
app/javascript/mastodon/features/search/index.tsx
Normal file
@ -0,0 +1,304 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { useSearchParam } from '@/hooks/useSearchParam';
|
||||
import FindInPageIcon from '@/material-icons/400-24px/find_in_page.svg?react';
|
||||
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
||||
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
||||
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
|
||||
import { submitSearch, expandSearch } from 'mastodon/actions/search';
|
||||
import type { ApiSearchType } from 'mastodon/api_types/search';
|
||||
import { Account } from 'mastodon/components/account';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import type { ColumnRef } from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
import { CompatibilityHashtag as Hashtag } from 'mastodon/components/hashtag';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||
import Status from 'mastodon/containers/status_container';
|
||||
import { Search } from 'mastodon/features/compose/components/search';
|
||||
import type { Hashtag as HashtagType } from 'mastodon/models/tags';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { SearchSection } from './components/search_section';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'search_results.title', defaultMessage: 'Search for "{q}"' },
|
||||
});
|
||||
|
||||
const INITIAL_PAGE_LIMIT = 10;
|
||||
const INITIAL_DISPLAY = 4;
|
||||
|
||||
const hidePeek = <T,>(list: T[]) => {
|
||||
if (
|
||||
list.length > INITIAL_PAGE_LIMIT &&
|
||||
list.length % INITIAL_PAGE_LIMIT === 1
|
||||
) {
|
||||
return list.slice(0, -2);
|
||||
} else {
|
||||
return list;
|
||||
}
|
||||
};
|
||||
|
||||
const renderAccounts = (accountIds: string[]) =>
|
||||
hidePeek<string>(accountIds).map((id) => <Account key={id} id={id} />);
|
||||
|
||||
const renderHashtags = (hashtags: HashtagType[]) =>
|
||||
hidePeek<HashtagType>(hashtags).map((hashtag) => (
|
||||
<Hashtag key={hashtag.name} hashtag={hashtag} />
|
||||
));
|
||||
|
||||
const renderStatuses = (statusIds: string[]) =>
|
||||
hidePeek<string>(statusIds).map((id) => (
|
||||
// @ts-expect-error inferred props are wrong
|
||||
<Status key={id} id={id} />
|
||||
));
|
||||
|
||||
type SearchType = 'all' | ApiSearchType;
|
||||
|
||||
const typeFromParam = (param?: string): SearchType => {
|
||||
if (param && ['all', 'accounts', 'statuses', 'hashtags'].includes(param)) {
|
||||
return param as SearchType;
|
||||
} else {
|
||||
return 'all';
|
||||
}
|
||||
};
|
||||
|
||||
export const SearchResults: React.FC<{ multiColumn: boolean }> = ({
|
||||
multiColumn,
|
||||
}) => {
|
||||
const columnRef = useRef<ColumnRef>(null);
|
||||
const intl = useIntl();
|
||||
const [q] = useSearchParam('q');
|
||||
const [type, setType] = useSearchParam('type');
|
||||
const isLoading = useAppSelector((state) => state.search.loading);
|
||||
const results = useAppSelector((state) => state.search.results);
|
||||
const dispatch = useAppDispatch();
|
||||
const mappedType = typeFromParam(type);
|
||||
const trimmedValue = q?.trim() ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
if (trimmedValue.length > 0) {
|
||||
void dispatch(
|
||||
submitSearch({
|
||||
q: trimmedValue,
|
||||
type: mappedType === 'all' ? undefined : mappedType,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, [dispatch, trimmedValue, mappedType]);
|
||||
|
||||
const handleHeaderClick = useCallback(() => {
|
||||
columnRef.current?.scrollTop();
|
||||
}, []);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
setType(null);
|
||||
}, [setType]);
|
||||
|
||||
const handleSelectAccounts = useCallback(() => {
|
||||
setType('accounts');
|
||||
}, [setType]);
|
||||
|
||||
const handleSelectHashtags = useCallback(() => {
|
||||
setType('hashtags');
|
||||
}, [setType]);
|
||||
|
||||
const handleSelectStatuses = useCallback(() => {
|
||||
setType('statuses');
|
||||
}, [setType]);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (mappedType !== 'all') {
|
||||
void dispatch(expandSearch({ type: mappedType }));
|
||||
}
|
||||
}, [dispatch, mappedType]);
|
||||
|
||||
// We request 1 more result than we display so we can tell if there'd be a next page
|
||||
const hasMore =
|
||||
mappedType !== 'all' && results
|
||||
? results[mappedType].length > INITIAL_PAGE_LIMIT &&
|
||||
results[mappedType].length % INITIAL_PAGE_LIMIT === 1
|
||||
: false;
|
||||
|
||||
let filteredResults;
|
||||
|
||||
if (results) {
|
||||
switch (mappedType) {
|
||||
case 'all':
|
||||
filteredResults =
|
||||
results.accounts.length +
|
||||
results.hashtags.length +
|
||||
results.statuses.length >
|
||||
0 ? (
|
||||
<>
|
||||
{results.accounts.length > 0 && (
|
||||
<SearchSection
|
||||
key='accounts'
|
||||
title={
|
||||
<>
|
||||
<Icon id='users' icon={PeopleIcon} />
|
||||
<FormattedMessage
|
||||
id='search_results.accounts'
|
||||
defaultMessage='Profiles'
|
||||
/>
|
||||
</>
|
||||
}
|
||||
onClickMore={handleSelectAccounts}
|
||||
>
|
||||
{results.accounts.slice(0, INITIAL_DISPLAY).map((id) => (
|
||||
<Account key={id} id={id} />
|
||||
))}
|
||||
</SearchSection>
|
||||
)}
|
||||
|
||||
{results.hashtags.length > 0 && (
|
||||
<SearchSection
|
||||
key='hashtags'
|
||||
title={
|
||||
<>
|
||||
<Icon id='hashtag' icon={TagIcon} />
|
||||
<FormattedMessage
|
||||
id='search_results.hashtags'
|
||||
defaultMessage='Hashtags'
|
||||
/>
|
||||
</>
|
||||
}
|
||||
onClickMore={handleSelectHashtags}
|
||||
>
|
||||
{results.hashtags.slice(0, INITIAL_DISPLAY).map((hashtag) => (
|
||||
<Hashtag key={hashtag.name} hashtag={hashtag} />
|
||||
))}
|
||||
</SearchSection>
|
||||
)}
|
||||
|
||||
{results.statuses.length > 0 && (
|
||||
<SearchSection
|
||||
key='statuses'
|
||||
title={
|
||||
<>
|
||||
<Icon id='quote-right' icon={FindInPageIcon} />
|
||||
<FormattedMessage
|
||||
id='search_results.statuses'
|
||||
defaultMessage='Posts'
|
||||
/>
|
||||
</>
|
||||
}
|
||||
onClickMore={handleSelectStatuses}
|
||||
>
|
||||
{results.statuses.slice(0, INITIAL_DISPLAY).map((id) => (
|
||||
// @ts-expect-error inferred props are wrong
|
||||
<Status key={id} id={id} />
|
||||
))}
|
||||
</SearchSection>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
[]
|
||||
);
|
||||
break;
|
||||
case 'accounts':
|
||||
filteredResults = renderAccounts(results.accounts);
|
||||
break;
|
||||
case 'hashtags':
|
||||
filteredResults = renderHashtags(results.hashtags);
|
||||
break;
|
||||
case 'statuses':
|
||||
filteredResults = renderStatuses(results.statuses);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
ref={columnRef}
|
||||
label={intl.formatMessage(messages.title, { q })}
|
||||
>
|
||||
<ColumnHeader
|
||||
icon={'search'}
|
||||
iconComponent={SearchIcon}
|
||||
title={intl.formatMessage(messages.title, { q })}
|
||||
onClick={handleHeaderClick}
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
|
||||
<div className='explore__search-header'>
|
||||
<Search singleColumn initialValue={trimmedValue} />
|
||||
</div>
|
||||
|
||||
<div className='account__section-headline'>
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
className={mappedType === 'all' ? 'active' : undefined}
|
||||
>
|
||||
<FormattedMessage id='search_results.all' defaultMessage='All' />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSelectAccounts}
|
||||
className={mappedType === 'accounts' ? 'active' : undefined}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='search_results.accounts'
|
||||
defaultMessage='Profiles'
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSelectHashtags}
|
||||
className={mappedType === 'hashtags' ? 'active' : undefined}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='search_results.hashtags'
|
||||
defaultMessage='Hashtags'
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSelectStatuses}
|
||||
className={mappedType === 'statuses' ? 'active' : undefined}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='search_results.statuses'
|
||||
defaultMessage='Posts'
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='explore__search-results' data-nosnippet>
|
||||
<ScrollableList
|
||||
scrollKey='search-results'
|
||||
isLoading={isLoading}
|
||||
showLoading={isLoading && !results}
|
||||
onLoadMore={handleLoadMore}
|
||||
hasMore={hasMore}
|
||||
emptyMessage={
|
||||
trimmedValue.length > 0 ? (
|
||||
<FormattedMessage
|
||||
id='search_results.no_results'
|
||||
defaultMessage='No results.'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='search_results.no_search_yet'
|
||||
defaultMessage='Try searching for posts, profiles or hashtags.'
|
||||
/>
|
||||
)
|
||||
}
|
||||
bindToDocument
|
||||
>
|
||||
{filteredResults}
|
||||
</ScrollableList>
|
||||
</div>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.title, { q })}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default SearchResults;
|
@ -5,8 +5,8 @@ import { connect } from 'react-redux';
|
||||
|
||||
import { changeComposing, mountCompose, unmountCompose } from 'mastodon/actions/compose';
|
||||
import ServerBanner from 'mastodon/components/server_banner';
|
||||
import { Search } from 'mastodon/features/compose/components/search';
|
||||
import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container';
|
||||
import SearchContainer from 'mastodon/features/compose/containers/search_container';
|
||||
import { LinkFooter } from 'mastodon/features/ui/components/link_footer';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
|
||||
@ -41,7 +41,7 @@ class ComposePanel extends PureComponent {
|
||||
|
||||
return (
|
||||
<div className='compose-panel' onFocus={this.onFocus}>
|
||||
<SearchContainer openInRoute />
|
||||
<Search openInRoute />
|
||||
|
||||
{!signedIn && (
|
||||
<>
|
||||
|
@ -69,6 +69,7 @@ import {
|
||||
OnboardingProfile,
|
||||
OnboardingFollows,
|
||||
Explore,
|
||||
Search,
|
||||
About,
|
||||
PrivacyPolicy,
|
||||
TermsOfService,
|
||||
@ -225,7 +226,8 @@ class SwitchingColumnsArea extends PureComponent {
|
||||
<WrappedRoute path={['/start', '/start/profile']} exact component={OnboardingProfile} content={children} />
|
||||
<WrappedRoute path='/start/follows' component={OnboardingFollows} content={children} />
|
||||
<WrappedRoute path='/directory' component={Directory} content={children} />
|
||||
<WrappedRoute path={['/explore', '/search']} component={Explore} content={children} />
|
||||
<WrappedRoute path='/explore' component={Explore} content={children} />
|
||||
<WrappedRoute path='/search' component={Search} content={children} />
|
||||
<WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
|
||||
|
||||
<WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />
|
||||
|
@ -174,6 +174,10 @@ export function Explore () {
|
||||
return import(/* webpackChunkName: "features/explore" */'../../explore');
|
||||
}
|
||||
|
||||
export function Search () {
|
||||
return import(/* webpackChunkName: "features/explore" */'../../search');
|
||||
}
|
||||
|
||||
export function FilterModal () {
|
||||
return import(/*webpackChunkName: "modals/filter_modal" */'../components/filter_modal');
|
||||
}
|
||||
|
@ -309,7 +309,6 @@
|
||||
"error.unexpected_crash.next_steps_addons": "Try disabling them and refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.",
|
||||
"errors.unexpected_crash.copy_stacktrace": "Copy stacktrace to clipboard",
|
||||
"errors.unexpected_crash.report_issue": "Report issue",
|
||||
"explore.search_results": "Search results",
|
||||
"explore.suggested_follows": "People",
|
||||
"explore.title": "Explore",
|
||||
"explore.trending_links": "News",
|
||||
@ -783,10 +782,11 @@
|
||||
"search_results.accounts": "Profiles",
|
||||
"search_results.all": "All",
|
||||
"search_results.hashtags": "Hashtags",
|
||||
"search_results.nothing_found": "Could not find anything for these search terms",
|
||||
"search_results.no_results": "No results.",
|
||||
"search_results.no_search_yet": "Try searching for posts, profiles or hashtags.",
|
||||
"search_results.see_all": "See all",
|
||||
"search_results.statuses": "Posts",
|
||||
"search_results.title": "Search for {q}",
|
||||
"search_results.title": "Search for \"{q}\"",
|
||||
"server_banner.about_active_users": "People using this server during the last 30 days (Monthly Active Users)",
|
||||
"server_banner.active_users": "active users",
|
||||
"server_banner.administered_by": "Administered by:",
|
||||
|
21
app/javascript/mastodon/models/search.ts
Normal file
21
app/javascript/mastodon/models/search.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import type { ApiSearchResultsJSON } from 'mastodon/api_types/search';
|
||||
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
|
||||
|
||||
export type SearchType = 'account' | 'hashtag' | 'accounts' | 'statuses';
|
||||
|
||||
export interface RecentSearch {
|
||||
q: string;
|
||||
type?: SearchType;
|
||||
}
|
||||
|
||||
export interface SearchResults {
|
||||
accounts: string[];
|
||||
statuses: string[];
|
||||
hashtags: ApiHashtagJSON[];
|
||||
}
|
||||
|
||||
export const createSearchResults = (serverJSON: ApiSearchResultsJSON) => ({
|
||||
accounts: serverJSON.accounts.map((account) => account.id),
|
||||
statuses: serverJSON.statuses.map((status) => status.id),
|
||||
hashtags: serverJSON.hashtags,
|
||||
});
|
@ -30,7 +30,7 @@ import { pictureInPictureReducer } from './picture_in_picture';
|
||||
import { pollsReducer } from './polls';
|
||||
import push_notifications from './push_notifications';
|
||||
import { relationshipsReducer } from './relationships';
|
||||
import search from './search';
|
||||
import { searchReducer } from './search';
|
||||
import server from './server';
|
||||
import settings from './settings';
|
||||
import status_lists from './status_lists';
|
||||
@ -60,7 +60,7 @@ const reducers = {
|
||||
server,
|
||||
contexts,
|
||||
compose,
|
||||
search,
|
||||
search: searchReducer,
|
||||
media_attachments,
|
||||
notifications,
|
||||
notificationGroups: notificationGroupsReducer,
|
||||
|
@ -1,84 +0,0 @@
|
||||
import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
|
||||
|
||||
import {
|
||||
COMPOSE_MENTION,
|
||||
COMPOSE_REPLY,
|
||||
COMPOSE_DIRECT,
|
||||
} from '../actions/compose';
|
||||
import {
|
||||
SEARCH_CHANGE,
|
||||
SEARCH_CLEAR,
|
||||
SEARCH_FETCH_REQUEST,
|
||||
SEARCH_FETCH_FAIL,
|
||||
SEARCH_FETCH_SUCCESS,
|
||||
SEARCH_SHOW,
|
||||
SEARCH_EXPAND_REQUEST,
|
||||
SEARCH_EXPAND_SUCCESS,
|
||||
SEARCH_EXPAND_FAIL,
|
||||
SEARCH_HISTORY_UPDATE,
|
||||
} from '../actions/search';
|
||||
|
||||
const initialState = ImmutableMap({
|
||||
value: '',
|
||||
submitted: false,
|
||||
hidden: false,
|
||||
results: ImmutableMap(),
|
||||
isLoading: false,
|
||||
searchTerm: '',
|
||||
type: null,
|
||||
recent: ImmutableOrderedSet(),
|
||||
});
|
||||
|
||||
export default function search(state = initialState, action) {
|
||||
switch(action.type) {
|
||||
case SEARCH_CHANGE:
|
||||
return state.set('value', action.value);
|
||||
case SEARCH_CLEAR:
|
||||
return state.withMutations(map => {
|
||||
map.set('value', '');
|
||||
map.set('results', ImmutableMap());
|
||||
map.set('submitted', false);
|
||||
map.set('hidden', false);
|
||||
map.set('searchTerm', '');
|
||||
map.set('type', null);
|
||||
});
|
||||
case SEARCH_SHOW:
|
||||
return state.set('hidden', false);
|
||||
case COMPOSE_REPLY:
|
||||
case COMPOSE_MENTION:
|
||||
case COMPOSE_DIRECT:
|
||||
return state.set('hidden', true);
|
||||
case SEARCH_FETCH_REQUEST:
|
||||
return state.withMutations(map => {
|
||||
map.set('results', ImmutableMap());
|
||||
map.set('isLoading', true);
|
||||
map.set('submitted', true);
|
||||
map.set('type', action.searchType);
|
||||
});
|
||||
case SEARCH_FETCH_FAIL:
|
||||
case SEARCH_EXPAND_FAIL:
|
||||
return state.set('isLoading', false);
|
||||
case SEARCH_FETCH_SUCCESS:
|
||||
return state.withMutations(map => {
|
||||
map.set('results', ImmutableMap({
|
||||
accounts: ImmutableOrderedSet(action.results.accounts.map(item => item.id)),
|
||||
statuses: ImmutableOrderedSet(action.results.statuses.map(item => item.id)),
|
||||
hashtags: ImmutableOrderedSet(fromJS(action.results.hashtags)),
|
||||
}));
|
||||
|
||||
map.set('searchTerm', action.searchTerm);
|
||||
map.set('type', action.searchType);
|
||||
map.set('isLoading', false);
|
||||
});
|
||||
case SEARCH_EXPAND_REQUEST:
|
||||
return state.set('type', action.searchType).set('isLoading', true);
|
||||
case SEARCH_EXPAND_SUCCESS: {
|
||||
const results = action.searchType === 'hashtags' ? ImmutableOrderedSet(fromJS(action.results.hashtags)) : action.results[action.searchType].map(item => item.id);
|
||||
return state.updateIn(['results', action.searchType], list => list.union(results)).set('isLoading', false);
|
||||
}
|
||||
case SEARCH_HISTORY_UPDATE:
|
||||
return state.set('recent', ImmutableOrderedSet(fromJS(action.recent)));
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
74
app/javascript/mastodon/reducers/search.ts
Normal file
74
app/javascript/mastodon/reducers/search.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { createReducer, isAnyOf } from '@reduxjs/toolkit';
|
||||
|
||||
import type { ApiSearchType } from 'mastodon/api_types/search';
|
||||
import type { RecentSearch, SearchResults } from 'mastodon/models/search';
|
||||
import { createSearchResults } from 'mastodon/models/search';
|
||||
|
||||
import {
|
||||
updateSearchHistory,
|
||||
submitSearch,
|
||||
expandSearch,
|
||||
} from '../actions/search';
|
||||
|
||||
interface State {
|
||||
recent: RecentSearch[];
|
||||
q: string;
|
||||
type?: ApiSearchType;
|
||||
loading: boolean;
|
||||
results?: SearchResults;
|
||||
}
|
||||
|
||||
const initialState: State = {
|
||||
recent: [],
|
||||
q: '',
|
||||
type: undefined,
|
||||
loading: false,
|
||||
results: undefined,
|
||||
};
|
||||
|
||||
export const searchReducer = createReducer(initialState, (builder) => {
|
||||
builder.addCase(submitSearch.fulfilled, (state, action) => {
|
||||
state.q = action.meta.arg.q;
|
||||
state.type = action.meta.arg.type;
|
||||
state.results = createSearchResults(action.payload);
|
||||
state.loading = false;
|
||||
});
|
||||
|
||||
builder.addCase(expandSearch.fulfilled, (state, action) => {
|
||||
const type = action.meta.arg.type;
|
||||
const results = createSearchResults(action.payload);
|
||||
|
||||
state.type = type;
|
||||
state.results = {
|
||||
accounts: state.results
|
||||
? [...state.results.accounts, ...results.accounts]
|
||||
: results.accounts,
|
||||
statuses: state.results
|
||||
? [...state.results.statuses, ...results.statuses]
|
||||
: results.statuses,
|
||||
hashtags: state.results
|
||||
? [...state.results.hashtags, ...results.hashtags]
|
||||
: results.hashtags,
|
||||
};
|
||||
state.loading = false;
|
||||
});
|
||||
|
||||
builder.addCase(updateSearchHistory, (state, action) => {
|
||||
state.recent = action.payload;
|
||||
});
|
||||
|
||||
builder.addMatcher(
|
||||
isAnyOf(expandSearch.pending, submitSearch.pending),
|
||||
(state, action) => {
|
||||
state.type = action.meta.arg.type;
|
||||
state.loading = true;
|
||||
},
|
||||
);
|
||||
|
||||
builder.addMatcher(
|
||||
isAnyOf(expandSearch.rejected, submitSearch.rejected),
|
||||
(state) => {
|
||||
state.loading = false;
|
||||
},
|
||||
);
|
||||
});
|
@ -3017,7 +3017,9 @@ $ui-header-logo-wordmark-width: 99px;
|
||||
|
||||
.column > .scrollable,
|
||||
.tabs-bar__wrapper .column-header,
|
||||
.tabs-bar__wrapper .column-back-button {
|
||||
.tabs-bar__wrapper .column-back-button,
|
||||
.explore__search-header,
|
||||
.column-search-header {
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
@ -3060,10 +3062,6 @@ $ui-header-logo-wordmark-width: 99px;
|
||||
}
|
||||
}
|
||||
|
||||
.explore__search-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.explore__suggestions__card {
|
||||
padding: 12px 16px;
|
||||
gap: 8px;
|
||||
@ -3137,10 +3135,6 @@ $ui-header-logo-wordmark-width: 99px;
|
||||
.columns-area__panels__pane--compositional {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.explore__search-header {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-with-badge {
|
||||
@ -5446,6 +5440,17 @@ a.status-card {
|
||||
}
|
||||
|
||||
.search__icon {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
top: 12px + 2px;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
margin-inline-start: 16px - 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
|
||||
&::-moz-focus-inner {
|
||||
border: 0;
|
||||
}
|
||||
@ -5457,17 +5462,14 @@ a.status-card {
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
top: 12px + 2px;
|
||||
display: inline-block;
|
||||
top: 0;
|
||||
inset-inline-start: 0;
|
||||
opacity: 0;
|
||||
transition: all 100ms linear;
|
||||
transition-property: transform, opacity;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: $darker-text-color;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
margin-inline-start: 16px - 2px;
|
||||
|
||||
&.active {
|
||||
pointer-events: auto;
|
||||
@ -8645,6 +8647,9 @@ noscript {
|
||||
.explore__search-header {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 1px solid var(--background-border-color);
|
||||
border-top: 0;
|
||||
border-bottom: 0;
|
||||
padding: 16px;
|
||||
padding-bottom: 8px;
|
||||
|
||||
@ -8663,13 +8668,21 @@ noscript {
|
||||
border: 1px solid var(--background-border-color);
|
||||
}
|
||||
|
||||
.search .icon {
|
||||
.search__icon {
|
||||
top: 12px;
|
||||
inset-inline-end: 12px;
|
||||
color: $dark-text-color;
|
||||
}
|
||||
}
|
||||
|
||||
.layout-single-column .explore__search-header {
|
||||
display: none;
|
||||
|
||||
@media screen and (max-width: $no-gap-breakpoint - 1px) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.explore__search-results {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
|
Loading…
Reference in New Issue
Block a user