Change search pop-out in web UI (#24305)
This commit is contained in:
parent
46483ae849
commit
2b11376411
10 changed files with 447 additions and 91 deletions
|
@ -14,6 +14,9 @@ export const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST';
|
||||||
export const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS';
|
export const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS';
|
||||||
export const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL';
|
export const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL';
|
||||||
|
|
||||||
|
export const SEARCH_RESULT_CLICK = 'SEARCH_RESULT_CLICK';
|
||||||
|
export const SEARCH_RESULT_FORGET = 'SEARCH_RESULT_FORGET';
|
||||||
|
|
||||||
export function changeSearch(value) {
|
export function changeSearch(value) {
|
||||||
return {
|
return {
|
||||||
type: SEARCH_CHANGE,
|
type: SEARCH_CHANGE,
|
||||||
|
@ -27,7 +30,7 @@ export function clearSearch() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function submitSearch() {
|
export function submitSearch(type) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
const value = getState().getIn(['search', 'value']);
|
const value = getState().getIn(['search', 'value']);
|
||||||
const signedIn = !!getState().getIn(['meta', 'me']);
|
const signedIn = !!getState().getIn(['meta', 'me']);
|
||||||
|
@ -44,6 +47,7 @@ export function submitSearch() {
|
||||||
q: value,
|
q: value,
|
||||||
resolve: signedIn,
|
resolve: signedIn,
|
||||||
limit: 5,
|
limit: 5,
|
||||||
|
type,
|
||||||
},
|
},
|
||||||
}).then(response => {
|
}).then(response => {
|
||||||
if (response.data.accounts) {
|
if (response.data.accounts) {
|
||||||
|
@ -130,3 +134,42 @@ export const expandSearchFail = error => ({
|
||||||
export const showSearch = () => ({
|
export const showSearch = () => ({
|
||||||
type: SEARCH_SHOW,
|
type: SEARCH_SHOW,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const openURL = routerHistory => (dispatch, getState) => {
|
||||||
|
const value = getState().getIn(['search', 'value']);
|
||||||
|
const signedIn = !!getState().getIn(['meta', 'me']);
|
||||||
|
|
||||||
|
if (!signedIn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(fetchSearchRequest());
|
||||||
|
|
||||||
|
api(getState).get('/api/v2/search', { params: { q: value, resolve: true } }).then(response => {
|
||||||
|
if (response.data.accounts?.length > 0) {
|
||||||
|
dispatch(importFetchedAccounts(response.data.accounts));
|
||||||
|
routerHistory.push(`/@${response.data.accounts[0].acct}`);
|
||||||
|
} else if (response.data.statuses?.length > 0) {
|
||||||
|
dispatch(importFetchedStatuses(response.data.statuses));
|
||||||
|
routerHistory.push(`/@${response.data.statuses[0].account.acct}/${response.data.statuses[0].id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(fetchSearchSuccess(response.data, value));
|
||||||
|
}).catch(err => {
|
||||||
|
dispatch(fetchSearchFail(err));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clickSearchResult = (q, type) => ({
|
||||||
|
type: SEARCH_RESULT_CLICK,
|
||||||
|
|
||||||
|
result: {
|
||||||
|
type,
|
||||||
|
q,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const forgetSearchResult = q => ({
|
||||||
|
type: SEARCH_RESULT_FORGET,
|
||||||
|
q,
|
||||||
|
});
|
||||||
|
|
|
@ -1,37 +1,17 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
import Overlay from 'react-overlays/Overlay';
|
import { searchEnabled } from 'mastodon/initial_state';
|
||||||
import { searchEnabled } from '../../../initial_state';
|
|
||||||
import Icon from 'mastodon/components/icon';
|
import Icon from 'mastodon/components/icon';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { HASHTAG_REGEX } from 'mastodon/utils/hashtags';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
|
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
|
||||||
placeholderSignedIn: { id: 'search.search_or_paste', defaultMessage: 'Search or paste URL' },
|
placeholderSignedIn: { id: 'search.search_or_paste', defaultMessage: 'Search or paste URL' },
|
||||||
});
|
});
|
||||||
|
|
||||||
class SearchPopout extends React.PureComponent {
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const extraInformation = searchEnabled ? <FormattedMessage id='search_popout.tips.full_text' defaultMessage='Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.' /> : <FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' />;
|
|
||||||
return (
|
|
||||||
<div className='search-popout'>
|
|
||||||
<h4><FormattedMessage id='search_popout.search_format' defaultMessage='Advanced search format' /></h4>
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
<li><em>#example</em> <FormattedMessage id='search_popout.tips.hashtag' defaultMessage='hashtag' /></li>
|
|
||||||
<li><em>@username@domain</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
|
|
||||||
<li><em>URL</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
|
|
||||||
<li><em>URL</em> <FormattedMessage id='search_popout.tips.status' defaultMessage='status' /></li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{extraInformation}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
class Search extends React.PureComponent {
|
class Search extends React.PureComponent {
|
||||||
|
|
||||||
static contextTypes = {
|
static contextTypes = {
|
||||||
|
@ -41,9 +21,13 @@ class Search extends React.PureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
value: PropTypes.string.isRequired,
|
value: PropTypes.string.isRequired,
|
||||||
|
recent: ImmutablePropTypes.orderedSet,
|
||||||
submitted: PropTypes.bool,
|
submitted: PropTypes.bool,
|
||||||
onChange: PropTypes.func.isRequired,
|
onChange: PropTypes.func.isRequired,
|
||||||
onSubmit: PropTypes.func.isRequired,
|
onSubmit: PropTypes.func.isRequired,
|
||||||
|
onOpenURL: PropTypes.func.isRequired,
|
||||||
|
onClickSearchResult: PropTypes.func.isRequired,
|
||||||
|
onForgetSearchResult: PropTypes.func.isRequired,
|
||||||
onClear: PropTypes.func.isRequired,
|
onClear: PropTypes.func.isRequired,
|
||||||
onShow: PropTypes.func.isRequired,
|
onShow: PropTypes.func.isRequired,
|
||||||
openInRoute: PropTypes.bool,
|
openInRoute: PropTypes.bool,
|
||||||
|
@ -53,44 +37,94 @@ class Search extends React.PureComponent {
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
expanded: false,
|
expanded: false,
|
||||||
|
selectedOption: -1,
|
||||||
|
options: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
setRef = c => {
|
setRef = c => {
|
||||||
this.searchForm = c;
|
this.searchForm = c;
|
||||||
};
|
};
|
||||||
|
|
||||||
handleChange = (e) => {
|
handleChange = ({ target }) => {
|
||||||
this.props.onChange(e.target.value);
|
const { onChange } = this.props;
|
||||||
|
|
||||||
|
onChange(target.value);
|
||||||
|
|
||||||
|
this._calculateOptions(target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
handleClear = (e) => {
|
handleClear = e => {
|
||||||
|
const { value, submitted, onClear } = this.props;
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (this.props.value.length > 0 || this.props.submitted) {
|
if (value.length > 0 || submitted) {
|
||||||
this.props.onClear();
|
onClear();
|
||||||
|
this.setState({ options: [], selectedOption: -1 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
handleKeyUp = (e) => {
|
handleKeyDown = (e) => {
|
||||||
if (e.key === 'Enter') {
|
const { selectedOption } = this.state;
|
||||||
|
const options = this._getOptions();
|
||||||
|
|
||||||
|
switch(e.key) {
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault();
|
||||||
|
this._unfocus();
|
||||||
|
|
||||||
|
break;
|
||||||
|
case 'ArrowDown':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
this.props.onSubmit();
|
if (options.length > 0) {
|
||||||
|
this.setState({ selectedOption: Math.min(selectedOption + 1, options.length - 1) });
|
||||||
if (this.props.openInRoute) {
|
|
||||||
this.context.router.history.push('/search');
|
|
||||||
}
|
}
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
document.querySelector('.ui').parentElement.focus();
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
this._unfocus();
|
||||||
|
|
||||||
|
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 = () => {
|
handleFocus = () => {
|
||||||
this.setState({ expanded: true });
|
const { onShow, singleColumn } = this.props;
|
||||||
this.props.onShow();
|
|
||||||
|
|
||||||
if (this.searchForm && !this.props.singleColumn) {
|
this.setState({ expanded: true, selectedOption: -1 });
|
||||||
|
onShow();
|
||||||
|
|
||||||
|
if (this.searchForm && !singleColumn) {
|
||||||
const { left, right } = this.searchForm.getBoundingClientRect();
|
const { left, right } = this.searchForm.getBoundingClientRect();
|
||||||
|
|
||||||
if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) {
|
if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) {
|
||||||
this.searchForm.scrollIntoView();
|
this.searchForm.scrollIntoView();
|
||||||
}
|
}
|
||||||
|
@ -98,21 +132,148 @@ class Search extends React.PureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
handleBlur = () => {
|
handleBlur = () => {
|
||||||
this.setState({ expanded: false });
|
this.setState({ expanded: false, selectedOption: -1 });
|
||||||
};
|
};
|
||||||
|
|
||||||
findTarget = () => {
|
findTarget = () => {
|
||||||
return this.searchForm;
|
return this.searchForm;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleHashtagClick = () => {
|
||||||
|
const { router } = this.context;
|
||||||
|
const { value, onClickSearchResult } = this.props;
|
||||||
|
|
||||||
|
const query = value.trim().replace(/^#/, '');
|
||||||
|
|
||||||
|
router.history.push(`/tags/${query}`);
|
||||||
|
onClickSearchResult(query, 'hashtag');
|
||||||
|
};
|
||||||
|
|
||||||
|
handleAccountClick = () => {
|
||||||
|
const { router } = this.context;
|
||||||
|
const { value, onClickSearchResult } = this.props;
|
||||||
|
|
||||||
|
const query = value.trim().replace(/^@/, '');
|
||||||
|
|
||||||
|
router.history.push(`/@${query}`);
|
||||||
|
onClickSearchResult(query, 'account');
|
||||||
|
};
|
||||||
|
|
||||||
|
handleURLClick = () => {
|
||||||
|
const { router } = this.context;
|
||||||
|
const { onOpenURL } = this.props;
|
||||||
|
|
||||||
|
onOpenURL(router.history);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleStatusSearch = () => {
|
||||||
|
this._submit('statuses');
|
||||||
|
};
|
||||||
|
|
||||||
|
handleAccountSearch = () => {
|
||||||
|
this._submit('accounts');
|
||||||
|
};
|
||||||
|
|
||||||
|
handleRecentSearchClick = search => {
|
||||||
|
const { router } = this.context;
|
||||||
|
|
||||||
|
if (search.get('type') === 'account') {
|
||||||
|
router.history.push(`/@${search.get('q')}`);
|
||||||
|
} else if (search.get('type') === 'hashtag') {
|
||||||
|
router.history.push(`/tags/${search.get('q')}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleForgetRecentSearchClick = search => {
|
||||||
|
const { onForgetSearchResult } = this.props;
|
||||||
|
|
||||||
|
onForgetSearchResult(search.get('q'));
|
||||||
|
};
|
||||||
|
|
||||||
|
_unfocus () {
|
||||||
|
document.querySelector('.ui').parentElement.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
_submit (type) {
|
||||||
|
const { onSubmit, openInRoute } = this.props;
|
||||||
|
const { router } = this.context;
|
||||||
|
|
||||||
|
onSubmit(type);
|
||||||
|
|
||||||
|
if (openInRoute) {
|
||||||
|
router.history.push('/search');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_getOptions () {
|
||||||
|
const { options } = this.state;
|
||||||
|
|
||||||
|
if (options.length > 0) {
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { recent } = this.props;
|
||||||
|
|
||||||
|
return recent.toArray().map(search => ({
|
||||||
|
label: search.get('type') === 'account' ? `@${search.get('q')}` : `#${search.get('q')}`,
|
||||||
|
|
||||||
|
action: () => this.handleRecentSearchClick(search),
|
||||||
|
|
||||||
|
forget: e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.handleForgetRecentSearchClick(search);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
_calculateOptions (value) {
|
||||||
|
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) {
|
||||||
|
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 () {
|
render () {
|
||||||
const { intl, value, submitted } = this.props;
|
const { intl, value, submitted, recent } = this.props;
|
||||||
const { expanded } = this.state;
|
const { expanded, options, selectedOption } = this.state;
|
||||||
const { signedIn } = this.context.identity;
|
const { signedIn } = this.context.identity;
|
||||||
|
|
||||||
const hasValue = value.length > 0 || submitted;
|
const hasValue = value.length > 0 || submitted;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='search'>
|
<div className={classNames('search', { active: expanded })}>
|
||||||
<input
|
<input
|
||||||
ref={this.setRef}
|
ref={this.setRef}
|
||||||
className='search__input'
|
className='search__input'
|
||||||
|
@ -121,7 +282,7 @@ class Search extends React.PureComponent {
|
||||||
aria-label={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
|
aria-label={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={this.handleChange}
|
onChange={this.handleChange}
|
||||||
onKeyUp={this.handleKeyUp}
|
onKeyDown={this.handleKeyDown}
|
||||||
onFocus={this.handleFocus}
|
onFocus={this.handleFocus}
|
||||||
onBlur={this.handleBlur}
|
onBlur={this.handleBlur}
|
||||||
/>
|
/>
|
||||||
|
@ -130,15 +291,41 @@ class Search extends React.PureComponent {
|
||||||
<Icon id='search' className={hasValue ? '' : 'active'} />
|
<Icon id='search' className={hasValue ? '' : 'active'} />
|
||||||
<Icon id='times-circle' className={hasValue ? 'active' : ''} aria-label={intl.formatMessage(messages.placeholder)} />
|
<Icon id='times-circle' className={hasValue ? 'active' : ''} aria-label={intl.formatMessage(messages.placeholder)} />
|
||||||
</div>
|
</div>
|
||||||
<Overlay show={expanded && !hasValue} placement='bottom' target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
|
|
||||||
{({ props, placement }) => (
|
<div className='search__popout'>
|
||||||
<div {...props} style={{ ...props.style, width: 285, zIndex: 2 }}>
|
{options.length === 0 && (
|
||||||
<div className={`dropdown-animation ${placement}`}>
|
<>
|
||||||
<SearchPopout />
|
<h4><FormattedMessage id='search_popout.recent' defaultMessage='Recent searches' /></h4>
|
||||||
|
|
||||||
|
<div className='search__popout__menu'>
|
||||||
|
{recent.size > 0 ? this._getOptions().map(({ label, action, forget }, i) => (
|
||||||
|
<button key={label} 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' /></button>
|
||||||
|
</button>
|
||||||
|
)) : (
|
||||||
|
<div className='search__popout__menu__message'>
|
||||||
|
<FormattedMessage id='search.no_recent_searches' defaultMessage='No recent searches' />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</Overlay>
|
|
||||||
|
{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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -77,7 +77,7 @@ class SearchResults extends ImmutablePureComponent {
|
||||||
count += results.get('accounts').size;
|
count += results.get('accounts').size;
|
||||||
accounts = (
|
accounts = (
|
||||||
<div className='search-results__section'>
|
<div className='search-results__section'>
|
||||||
<h5><Icon id='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
|
<h5><Icon id='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></h5>
|
||||||
|
|
||||||
{results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)}
|
{results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)}
|
||||||
|
|
||||||
|
|
|
@ -4,12 +4,16 @@ import {
|
||||||
clearSearch,
|
clearSearch,
|
||||||
submitSearch,
|
submitSearch,
|
||||||
showSearch,
|
showSearch,
|
||||||
} from '../../../actions/search';
|
openURL,
|
||||||
|
clickSearchResult,
|
||||||
|
forgetSearchResult,
|
||||||
|
} from 'mastodon/actions/search';
|
||||||
import Search from '../components/search';
|
import Search from '../components/search';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
value: state.getIn(['search', 'value']),
|
value: state.getIn(['search', 'value']),
|
||||||
submitted: state.getIn(['search', 'submitted']),
|
submitted: state.getIn(['search', 'submitted']),
|
||||||
|
recent: state.getIn(['search', 'recent']),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = dispatch => ({
|
const mapDispatchToProps = dispatch => ({
|
||||||
|
@ -22,14 +26,26 @@ const mapDispatchToProps = dispatch => ({
|
||||||
dispatch(clearSearch());
|
dispatch(clearSearch());
|
||||||
},
|
},
|
||||||
|
|
||||||
onSubmit () {
|
onSubmit (type) {
|
||||||
dispatch(submitSearch());
|
dispatch(submitSearch(type));
|
||||||
},
|
},
|
||||||
|
|
||||||
onShow () {
|
onShow () {
|
||||||
dispatch(showSearch());
|
dispatch(showSearch());
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onOpenURL (routerHistory) {
|
||||||
|
dispatch(openURL(routerHistory));
|
||||||
|
},
|
||||||
|
|
||||||
|
onClickSearchResult (q, type) {
|
||||||
|
dispatch(clickSearchResult(q, type));
|
||||||
|
},
|
||||||
|
|
||||||
|
onForgetSearchResult (q) {
|
||||||
|
dispatch(forgetSearchResult(q));
|
||||||
|
},
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(Search);
|
export default connect(mapStateToProps, mapDispatchToProps)(Search);
|
||||||
|
|
|
@ -3,36 +3,12 @@ import { connect } from 'react-redux';
|
||||||
import Warning from '../components/warning';
|
import Warning from '../components/warning';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import { me } from '../../../initial_state';
|
import { me } from 'mastodon/initial_state';
|
||||||
|
import { HASHTAG_PATTERN_REGEX } from 'mastodon/utils/hashtags';
|
||||||
const buildHashtagRE = () => {
|
|
||||||
try {
|
|
||||||
const HASHTAG_SEPARATORS = '_\\u00b7\\u200c';
|
|
||||||
const ALPHA = '\\p{L}\\p{M}';
|
|
||||||
const WORD = '\\p{L}\\p{M}\\p{N}\\p{Pc}';
|
|
||||||
return new RegExp(
|
|
||||||
'(?:^|[^\\/\\)\\w])#((' +
|
|
||||||
'[' + WORD + '_]' +
|
|
||||||
'[' + WORD + HASHTAG_SEPARATORS + ']*' +
|
|
||||||
'[' + ALPHA + HASHTAG_SEPARATORS + ']' +
|
|
||||||
'[' + WORD + HASHTAG_SEPARATORS +']*' +
|
|
||||||
'[' + WORD + '_]' +
|
|
||||||
')|(' +
|
|
||||||
'[' + WORD + '_]*' +
|
|
||||||
'[' + ALPHA + ']' +
|
|
||||||
'[' + WORD + '_]*' +
|
|
||||||
'))', 'iu',
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return /(?:^|[^/)\w])#(\w*[a-zA-Z·]\w*)/i;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const APPROX_HASHTAG_RE = buildHashtagRE();
|
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
|
needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
|
||||||
hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && APPROX_HASHTAG_RE.test(state.getIn(['compose', 'text'])),
|
hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && HASHTAG_PATTERN_REGEX.test(state.getIn(['compose', 'text'])),
|
||||||
directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct',
|
directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -105,7 +105,7 @@ class Results extends React.PureComponent {
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<div className='account__section-headline'>
|
<div className='account__section-headline'>
|
||||||
<button onClick={this.handleSelectAll} className={type === 'all' && 'active'}><FormattedMessage id='search_results.all' defaultMessage='All' /></button>
|
<button onClick={this.handleSelectAll} className={type === 'all' && 'active'}><FormattedMessage id='search_results.all' defaultMessage='All' /></button>
|
||||||
<button onClick={this.handleSelectAccounts} className={type === 'accounts' && 'active'}><FormattedMessage id='search_results.accounts' defaultMessage='People' /></button>
|
<button onClick={this.handleSelectAccounts} className={type === 'accounts' && 'active'}><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></button>
|
||||||
<button onClick={this.handleSelectHashtags} className={type === 'hashtags' && 'active'}><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></button>
|
<button onClick={this.handleSelectHashtags} className={type === 'hashtags' && 'active'}><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></button>
|
||||||
<button onClick={this.handleSelectStatuses} className={type === 'statuses' && 'active'}><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></button>
|
<button onClick={this.handleSelectStatuses} className={type === 'statuses' && 'active'}><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -530,7 +530,7 @@
|
||||||
"search_popout.tips.status": "post",
|
"search_popout.tips.status": "post",
|
||||||
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
|
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
|
||||||
"search_popout.tips.user": "user",
|
"search_popout.tips.user": "user",
|
||||||
"search_results.accounts": "People",
|
"search_results.accounts": "Profiles",
|
||||||
"search_results.all": "All",
|
"search_results.all": "All",
|
||||||
"search_results.hashtags": "Hashtags",
|
"search_results.hashtags": "Hashtags",
|
||||||
"search_results.nothing_found": "Could not find anything for these search terms",
|
"search_results.nothing_found": "Could not find anything for these search terms",
|
||||||
|
|
|
@ -6,13 +6,15 @@ import {
|
||||||
SEARCH_FETCH_SUCCESS,
|
SEARCH_FETCH_SUCCESS,
|
||||||
SEARCH_SHOW,
|
SEARCH_SHOW,
|
||||||
SEARCH_EXPAND_SUCCESS,
|
SEARCH_EXPAND_SUCCESS,
|
||||||
|
SEARCH_RESULT_CLICK,
|
||||||
|
SEARCH_RESULT_FORGET,
|
||||||
} from '../actions/search';
|
} from '../actions/search';
|
||||||
import {
|
import {
|
||||||
COMPOSE_MENTION,
|
COMPOSE_MENTION,
|
||||||
COMPOSE_REPLY,
|
COMPOSE_REPLY,
|
||||||
COMPOSE_DIRECT,
|
COMPOSE_DIRECT,
|
||||||
} from '../actions/compose';
|
} from '../actions/compose';
|
||||||
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
|
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
|
||||||
|
|
||||||
const initialState = ImmutableMap({
|
const initialState = ImmutableMap({
|
||||||
value: '',
|
value: '',
|
||||||
|
@ -21,6 +23,7 @@ const initialState = ImmutableMap({
|
||||||
results: ImmutableMap(),
|
results: ImmutableMap(),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
searchTerm: '',
|
searchTerm: '',
|
||||||
|
recent: ImmutableOrderedSet(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function search(state = initialState, action) {
|
export default function search(state = initialState, action) {
|
||||||
|
@ -61,6 +64,10 @@ export default function search(state = initialState, action) {
|
||||||
case SEARCH_EXPAND_SUCCESS:
|
case SEARCH_EXPAND_SUCCESS:
|
||||||
const results = action.searchType === 'hashtags' ? fromJS(action.results.hashtags) : action.results[action.searchType].map(item => item.id);
|
const results = action.searchType === 'hashtags' ? fromJS(action.results.hashtags) : action.results[action.searchType].map(item => item.id);
|
||||||
return state.updateIn(['results', action.searchType], list => list.concat(results));
|
return state.updateIn(['results', action.searchType], list => list.concat(results));
|
||||||
|
case SEARCH_RESULT_CLICK:
|
||||||
|
return state.update('recent', set => set.add(fromJS(action.result)));
|
||||||
|
case SEARCH_RESULT_FORGET:
|
||||||
|
return state.update('recent', set => set.filterNot(result => result.get('q') === action.q));
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
47
app/javascript/mastodon/utils/hashtags.js
Normal file
47
app/javascript/mastodon/utils/hashtags.js
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
const HASHTAG_SEPARATORS = '_\\u00b7\\u200c';
|
||||||
|
const ALPHA = '\\p{L}\\p{M}';
|
||||||
|
const WORD = '\\p{L}\\p{M}\\p{N}\\p{Pc}';
|
||||||
|
|
||||||
|
const buildHashtagPatternRegex = () => {
|
||||||
|
try {
|
||||||
|
return new RegExp(
|
||||||
|
'(?:^|[^\\/\\)\\w])#((' +
|
||||||
|
'[' + WORD + '_]' +
|
||||||
|
'[' + WORD + HASHTAG_SEPARATORS + ']*' +
|
||||||
|
'[' + ALPHA + HASHTAG_SEPARATORS + ']' +
|
||||||
|
'[' + WORD + HASHTAG_SEPARATORS +']*' +
|
||||||
|
'[' + WORD + '_]' +
|
||||||
|
')|(' +
|
||||||
|
'[' + WORD + '_]*' +
|
||||||
|
'[' + ALPHA + ']' +
|
||||||
|
'[' + WORD + '_]*' +
|
||||||
|
'))', 'iu',
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return /(?:^|[^/)\w])#(\w*[a-zA-Z·]\w*)/i;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildHashtagRegex = () => {
|
||||||
|
try {
|
||||||
|
return new RegExp(
|
||||||
|
'^((' +
|
||||||
|
'[' + WORD + '_]' +
|
||||||
|
'[' + WORD + HASHTAG_SEPARATORS + ']*' +
|
||||||
|
'[' + ALPHA + HASHTAG_SEPARATORS + ']' +
|
||||||
|
'[' + WORD + HASHTAG_SEPARATORS +']*' +
|
||||||
|
'[' + WORD + '_]' +
|
||||||
|
')|(' +
|
||||||
|
'[' + WORD + '_]*' +
|
||||||
|
'[' + ALPHA + ']' +
|
||||||
|
'[' + WORD + '_]*' +
|
||||||
|
'))$', 'iu',
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return /^(\w*[a-zA-Z·]\w*)$/i;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HASHTAG_PATTERN_REGEX = buildHashtagPatternRegex();
|
||||||
|
|
||||||
|
export const HASHTAG_REGEX = buildHashtagRegex();
|
|
@ -4816,6 +4816,86 @@ a.status-card.compact:hover {
|
||||||
.search {
|
.search {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
&__popout {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-start: 0;
|
||||||
|
margin-top: -2px;
|
||||||
|
width: 100%;
|
||||||
|
background: $ui-base-color;
|
||||||
|
border-radius: 0 0 4px 4px;
|
||||||
|
box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);
|
||||||
|
z-index: 2;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 15px 5px;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: $dark-text-color;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__menu {
|
||||||
|
&__message {
|
||||||
|
color: $dark-text-color;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
display: block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
font: inherit;
|
||||||
|
background: transparent;
|
||||||
|
color: $darker-text-color;
|
||||||
|
padding: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: start;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&--flex {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus,
|
||||||
|
&:active,
|
||||||
|
&.selected {
|
||||||
|
background: $ui-highlight-color;
|
||||||
|
color: $primary-text-color;
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
color: $primary-text-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mark {
|
||||||
|
background: transparent;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $primary-text-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
.search__popout {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.search__input {
|
.search__input {
|
||||||
|
@ -6695,10 +6775,6 @@ a.status-card.compact:hover {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-popout {
|
|
||||||
@include search-popout;
|
|
||||||
}
|
|
||||||
|
|
||||||
noscript {
|
noscript {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
|
@ -7985,6 +8061,10 @@ noscript {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search__popout {
|
||||||
|
border: 1px solid lighten($ui-base-color, 8%);
|
||||||
|
}
|
||||||
|
|
||||||
.search .fa {
|
.search .fa {
|
||||||
top: 10px;
|
top: 10px;
|
||||||
inset-inline-end: 10px;
|
inset-inline-end: 10px;
|
||||||
|
|
Loading…
Reference in a new issue