mirror of
https://github.com/mastodon/mastodon.git
synced 2024-12-22 17:35:40 +01:00
Change search to use query params in web UI (#32949)
This commit is contained in:
parent
708919ee93
commit
0636bcdbe1
28 changed files with 1396 additions and 1270 deletions
|
@ -2,6 +2,8 @@ import { useCallback } from 'react';
|
||||||
|
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { isFulfilled, isRejected } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
import { openURL } from 'mastodon/actions/search';
|
import { openURL } from 'mastodon/actions/search';
|
||||||
import { useAppDispatch } from 'mastodon/store';
|
import { useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
|
@ -28,12 +30,22 @@ export const useLinks = () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMentionClick = useCallback(
|
const handleMentionClick = useCallback(
|
||||||
(element: HTMLAnchorElement) => {
|
async (element: HTMLAnchorElement) => {
|
||||||
dispatch(
|
const result = await dispatch(openURL({ url: element.href }));
|
||||||
openURL(element.href, history, () => {
|
|
||||||
|
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;
|
window.location.href = element.href;
|
||||||
}),
|
}
|
||||||
);
|
} else if (isRejected(result)) {
|
||||||
|
window.location.href = element.href;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[dispatch, history],
|
[dispatch, history],
|
||||||
);
|
);
|
||||||
|
@ -48,7 +60,7 @@ export const useLinks = () => {
|
||||||
|
|
||||||
if (isMentionClick(target)) {
|
if (isMentionClick(target)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleMentionClick(target);
|
void handleMentionClick(target);
|
||||||
} else if (isHashtagClick(target)) {
|
} else if (isHashtagClick(target)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleHashtagClick(target);
|
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 { ShortNumber } from 'mastodon/components/short_number';
|
||||||
import { Skeleton } from 'mastodon/components/skeleton';
|
import { Skeleton } from 'mastodon/components/skeleton';
|
||||||
|
import type { Hashtag as HashtagType } from 'mastodon/models/tags';
|
||||||
|
|
||||||
interface SilentErrorBoundaryProps {
|
interface SilentErrorBoundaryProps {
|
||||||
children: React.ReactNode;
|
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 {
|
export interface HashtagProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
description?: React.ReactNode;
|
description?: React.ReactNode;
|
||||||
|
|
|
@ -6,6 +6,7 @@ import classNames from 'classnames';
|
||||||
import { Helmet } from 'react-helmet';
|
import { Helmet } from 'react-helmet';
|
||||||
import { NavLink, withRouter } from 'react-router-dom';
|
import { NavLink, withRouter } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { isFulfilled, isRejected } from '@reduxjs/toolkit';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
|
||||||
|
@ -215,8 +216,20 @@ class Header extends ImmutablePureComponent {
|
||||||
|
|
||||||
const link = e.currentTarget;
|
const link = e.currentTarget;
|
||||||
|
|
||||||
onOpenURL(link.href, history, () => {
|
onOpenURL(link.href).then((result) => {
|
||||||
window.location = link.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 = link.href;
|
||||||
|
}
|
||||||
|
} else if (isRejected(result)) {
|
||||||
|
window.location = link.href;
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
// Nothing
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -144,8 +144,8 @@ const mapDispatchToProps = (dispatch) => ({
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
onOpenURL (url, routerHistory, onFailure) {
|
onOpenURL (url) {
|
||||||
dispatch(openURL(url, routerHistory, onFailure));
|
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 ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
import spring from 'react-motion/lib/spring';
|
|
||||||
|
|
||||||
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
||||||
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
||||||
import LogoutIcon from '@/material-icons/400-24px/logout.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 { changeComposing, mountCompose, unmountCompose } from '../../actions/compose';
|
||||||
import { mascot } from '../../initial_state';
|
import { mascot } from '../../initial_state';
|
||||||
import { isMobile } from '../../is_mobile';
|
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 ComposeFormContainer from './containers/compose_form_container';
|
||||||
import SearchContainer from './containers/search_container';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
|
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
|
||||||
|
@ -43,9 +39,8 @@ const messages = defineMessages({
|
||||||
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' },
|
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapStateToProps = (state, ownProps) => ({
|
const mapStateToProps = (state) => ({
|
||||||
columns: state.getIn(['settings', 'columns']),
|
columns: state.getIn(['settings', 'columns']),
|
||||||
showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
class Compose extends PureComponent {
|
class Compose extends PureComponent {
|
||||||
|
@ -54,7 +49,6 @@ class Compose extends PureComponent {
|
||||||
dispatch: PropTypes.func.isRequired,
|
dispatch: PropTypes.func.isRequired,
|
||||||
columns: ImmutablePropTypes.list.isRequired,
|
columns: ImmutablePropTypes.list.isRequired,
|
||||||
multiColumn: PropTypes.bool,
|
multiColumn: PropTypes.bool,
|
||||||
showSearch: PropTypes.bool,
|
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -88,7 +82,7 @@ class Compose extends PureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { multiColumn, showSearch, intl } = this.props;
|
const { multiColumn, intl } = this.props;
|
||||||
|
|
||||||
if (multiColumn) {
|
if (multiColumn) {
|
||||||
const { columns } = this.props;
|
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>
|
<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>
|
</nav>
|
||||||
|
|
||||||
{multiColumn && <SearchContainer /> }
|
{multiColumn && <Search /> }
|
||||||
|
|
||||||
<div className='drawer__pager'>
|
<div className='drawer__pager'>
|
||||||
<div className='drawer__inner' onFocus={this.onFocus}>
|
<div className='drawer__inner' onFocus={this.onFocus}>
|
||||||
|
@ -123,14 +117,6 @@ class Compose extends PureComponent {
|
||||||
<img alt='' draggable='false' src={mascot || elephantUIPlane} />
|
<img alt='' draggable='false' src={mascot || elephantUIPlane} />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</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 { changeComposing, mountCompose, unmountCompose } from 'mastodon/actions/compose';
|
||||||
import ServerBanner from 'mastodon/components/server_banner';
|
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 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 { LinkFooter } from 'mastodon/features/ui/components/link_footer';
|
||||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||||
|
|
||||||
|
@ -41,7 +41,7 @@ class ComposePanel extends PureComponent {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='compose-panel' onFocus={this.onFocus}>
|
<div className='compose-panel' onFocus={this.onFocus}>
|
||||||
<SearchContainer openInRoute />
|
<Search openInRoute />
|
||||||
|
|
||||||
{!signedIn && (
|
{!signedIn && (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -69,6 +69,7 @@ import {
|
||||||
OnboardingProfile,
|
OnboardingProfile,
|
||||||
OnboardingFollows,
|
OnboardingFollows,
|
||||||
Explore,
|
Explore,
|
||||||
|
Search,
|
||||||
About,
|
About,
|
||||||
PrivacyPolicy,
|
PrivacyPolicy,
|
||||||
TermsOfService,
|
TermsOfService,
|
||||||
|
@ -225,7 +226,8 @@ class SwitchingColumnsArea extends PureComponent {
|
||||||
<WrappedRoute path={['/start', '/start/profile']} exact component={OnboardingProfile} content={children} />
|
<WrappedRoute path={['/start', '/start/profile']} exact component={OnboardingProfile} content={children} />
|
||||||
<WrappedRoute path='/start/follows' component={OnboardingFollows} content={children} />
|
<WrappedRoute path='/start/follows' component={OnboardingFollows} content={children} />
|
||||||
<WrappedRoute path='/directory' component={Directory} 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={['/publish', '/statuses/new']} component={Compose} content={children} />
|
||||||
|
|
||||||
<WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} 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');
|
return import(/* webpackChunkName: "features/explore" */'../../explore');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function Search () {
|
||||||
|
return import(/* webpackChunkName: "features/explore" */'../../search');
|
||||||
|
}
|
||||||
|
|
||||||
export function FilterModal () {
|
export function FilterModal () {
|
||||||
return import(/*webpackChunkName: "modals/filter_modal" */'../components/filter_modal');
|
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.",
|
"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.copy_stacktrace": "Copy stacktrace to clipboard",
|
||||||
"errors.unexpected_crash.report_issue": "Report issue",
|
"errors.unexpected_crash.report_issue": "Report issue",
|
||||||
"explore.search_results": "Search results",
|
|
||||||
"explore.suggested_follows": "People",
|
"explore.suggested_follows": "People",
|
||||||
"explore.title": "Explore",
|
"explore.title": "Explore",
|
||||||
"explore.trending_links": "News",
|
"explore.trending_links": "News",
|
||||||
|
@ -783,10 +782,11 @@
|
||||||
"search_results.accounts": "Profiles",
|
"search_results.accounts": "Profiles",
|
||||||
"search_results.all": "All",
|
"search_results.all": "All",
|
||||||
"search_results.hashtags": "Hashtags",
|
"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.see_all": "See all",
|
||||||
"search_results.statuses": "Posts",
|
"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.about_active_users": "People using this server during the last 30 days (Monthly Active Users)",
|
||||||
"server_banner.active_users": "active users",
|
"server_banner.active_users": "active users",
|
||||||
"server_banner.administered_by": "Administered by:",
|
"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 { pollsReducer } from './polls';
|
||||||
import push_notifications from './push_notifications';
|
import push_notifications from './push_notifications';
|
||||||
import { relationshipsReducer } from './relationships';
|
import { relationshipsReducer } from './relationships';
|
||||||
import search from './search';
|
import { searchReducer } from './search';
|
||||||
import server from './server';
|
import server from './server';
|
||||||
import settings from './settings';
|
import settings from './settings';
|
||||||
import status_lists from './status_lists';
|
import status_lists from './status_lists';
|
||||||
|
@ -60,7 +60,7 @@ const reducers = {
|
||||||
server,
|
server,
|
||||||
contexts,
|
contexts,
|
||||||
compose,
|
compose,
|
||||||
search,
|
search: searchReducer,
|
||||||
media_attachments,
|
media_attachments,
|
||||||
notifications,
|
notifications,
|
||||||
notificationGroups: notificationGroupsReducer,
|
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,
|
.column > .scrollable,
|
||||||
.tabs-bar__wrapper .column-header,
|
.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-left: 0;
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
}
|
}
|
||||||
|
@ -3060,10 +3062,6 @@ $ui-header-logo-wordmark-width: 99px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.explore__search-header {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.explore__suggestions__card {
|
.explore__suggestions__card {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
@ -3137,10 +3135,6 @@ $ui-header-logo-wordmark-width: 99px;
|
||||||
.columns-area__panels__pane--compositional {
|
.columns-area__panels__pane--compositional {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.explore__search-header {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-with-badge {
|
.icon-with-badge {
|
||||||
|
@ -5446,6 +5440,17 @@ a.status-card {
|
||||||
}
|
}
|
||||||
|
|
||||||
.search__icon {
|
.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 {
|
&::-moz-focus-inner {
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
@ -5457,17 +5462,14 @@ a.status-card {
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 12px + 2px;
|
top: 0;
|
||||||
display: inline-block;
|
inset-inline-start: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: all 100ms linear;
|
transition: all 100ms linear;
|
||||||
transition-property: transform, opacity;
|
transition-property: transform, opacity;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
color: $darker-text-color;
|
color: $darker-text-color;
|
||||||
cursor: default;
|
|
||||||
pointer-events: none;
|
|
||||||
margin-inline-start: 16px - 2px;
|
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
|
@ -8645,6 +8647,9 @@ noscript {
|
||||||
.explore__search-header {
|
.explore__search-header {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
border: 1px solid var(--background-border-color);
|
||||||
|
border-top: 0;
|
||||||
|
border-bottom: 0;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
|
|
||||||
|
@ -8663,13 +8668,21 @@ noscript {
|
||||||
border: 1px solid var(--background-border-color);
|
border: 1px solid var(--background-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search .icon {
|
.search__icon {
|
||||||
top: 12px;
|
top: 12px;
|
||||||
inset-inline-end: 12px;
|
inset-inline-end: 12px;
|
||||||
color: $dark-text-color;
|
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 {
|
.explore__search-results {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
Loading…
Reference in a new issue