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