Port search popout changes from upstream (#2333)

* [Glitch] Change search pop-out in web UI

Port 2b11376411 to glitch-soc

Signed-off-by: Plastikmensch <plastikmensch@users.noreply.github.com>

* [Glitch] Change logo version in header based on screen size in web UI

Port remaining change from 6028d047b9 to glitch-soc

Signed-off-by: Plastikmensch <plastikmensch@users.noreply.github.com>

* Rename `hashtags.js` to `hashtags.ts` and apply code style changes

Apply changes from e38b391940 (rename to ts), 51b83ed195 (code style change), 73b64b8917 (additional code style change)

Signed-off-by: Plastikmensch <plastikmensch@users.noreply.github.com>

* Remove strings for old search

Signed-off-by: Plastikmensch <plastikmensch@users.noreply.github.com>

---------

Signed-off-by: Plastikmensch <plastikmensch@users.noreply.github.com>
This commit is contained in:
Plastikmensch 2023-07-30 18:42:35 +02:00 committed by GitHub
parent 3a14104411
commit ebfa184e74
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 422 additions and 94 deletions

View File

@ -15,6 +15,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,
@ -28,7 +31,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']);
@ -45,6 +48,7 @@ export function submitSearch() {
q: value, q: value,
resolve: signedIn, resolve: signedIn,
limit: 10, limit: 10,
type,
}, },
}).then(response => { }).then(response => {
if (response.data.accounts) { if (response.data.accounts) {
@ -131,3 +135,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,
});

View File

@ -7,39 +7,21 @@ import {
defineMessages, defineMessages,
} from 'react-intl'; } from 'react-intl';
import Overlay from 'react-overlays/Overlay'; import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { Icon } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon';
import { searchEnabled } from 'flavours/glitch/initial_state'; import { searchEnabled } from 'flavours/glitch/initial_state';
import { focusRoot } from 'flavours/glitch/utils/dom_helpers'; import { focusRoot } from 'flavours/glitch/utils/dom_helpers';
import { HASHTAG_REGEX } from 'flavours/glitch/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 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>
);
}
}
// The component. // The component.
class Search extends PureComponent { class Search extends PureComponent {
@ -50,9 +32,13 @@ class Search extends 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,
@ -62,59 +48,104 @@ class Search extends PureComponent {
state = { state = {
expanded: false, expanded: false,
selectedOption: -1,
options: [],
}; };
setRef = c => { setRef = c => {
this.searchForm = c; this.searchForm = c;
}; };
handleChange = (e) => { handleChange = ({ target }) => {
const { onChange } = this.props; const { onChange } = this.props;
if (onChange) {
onChange(e.target.value); onChange(target.value);
}
this._calculateOptions(target.value);
}; };
handleClear = (e) => { handleClear = e => {
const { const {
onClear, onClear,
submitted, submitted,
value, value,
} = this.props; } = this.props;
e.preventDefault(); // Prevents focus change ?? e.preventDefault(); // Prevents focus change ??
if (onClear && (submitted || value && value.length)) {
if (value.length > 0 || submitted) {
onClear(); onClear();
this.setState({ options: [], selectedOption: -1 })
} }
}; };
handleBlur = () => { handleBlur = () => {
this.setState({ expanded: false }); this.setState({ expanded: false, selectedOption: -1 });
}; };
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();
} }
} }
}; };
handleKeyUp = (e) => { handleKeyDown = (e) => {
const { onSubmit } = this.props; const { selectedOption } = this.state;
switch (e.key) { const options = this._getOptions();
case 'Enter':
onSubmit();
if (this.props.openInRoute) { switch(e.key) {
this.context.router.history.push('/search'); case 'Escape':
e.preventDefault();
focusRoot();
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();
}
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; break;
case 'Escape':
focusRoot();
} }
}; };
@ -122,14 +153,141 @@ class Search extends PureComponent {
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'
@ -138,7 +296,7 @@ class Search extends 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}
/> />
@ -147,15 +305,39 @@ class Search extends PureComponent {
<Icon id='search' className={hasValue ? '' : 'active'} /> <Icon id='search' className={hasValue ? '' : 'active'} />
<Icon id='times-circle' className={hasValue ? 'active' : ''} /> <Icon id='times-circle' className={hasValue ? 'active' : ''} />
</div> </div>
<Overlay show={expanded && !hasValue} placement='bottom' target={this.findTarget} popperConfig={{ strategy: 'fixed' }}> <div className='search__popout'>
{({ props, placement }) => ( {options.length === 0 && (
<div {...props} style={{ ...props.style, width: 285, zIndex: 2 }}> <>
<div className={`dropdown-animation ${placement}`}> <h4><FormattedMessage id='search_popout.recent' defaultMessage='Recent searches' /></h4>
<SearchPopout />
<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>
); );
} }

View File

@ -89,7 +89,7 @@ class SearchResults extends ImmutablePureComponent {
count += results.get('accounts').size; count += results.get('accounts').size;
accounts = ( accounts = (
<section className='search-results__section'> <section 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 id={accountId} key={accountId} />)} {results.get('accounts').map(accountId => <AccountContainer id={accountId} key={accountId} />)}

View File

@ -5,6 +5,9 @@ import {
clearSearch, clearSearch,
submitSearch, submitSearch,
showSearch, showSearch,
openURL,
clickSearchResult,
forgetSearchResult,
} from 'flavours/glitch/actions/search'; } from 'flavours/glitch/actions/search';
import Search from '../components/search'; import Search from '../components/search';
@ -12,6 +15,7 @@ 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 => ({
@ -24,14 +28,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);

View File

@ -6,37 +6,14 @@ import { connect } from 'react-redux';
import { me } from 'flavours/glitch/initial_state'; import { me } from 'flavours/glitch/initial_state';
import { profileLink, privacyPolicyLink } from 'flavours/glitch/utils/backend_links'; import { profileLink, privacyPolicyLink } from 'flavours/glitch/utils/backend_links';
import { HASHTAG_PATTERN_REGEX } from 'flavours/glitch/utils/hashtags';
import Warning from '../components/warning'; import Warning from '../components/warning';
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',
}); });

View File

@ -111,7 +111,7 @@ class Results extends PureComponent {
<> <>
<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>

View File

@ -98,12 +98,6 @@
"onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.", "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
"onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.", "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
"onboarding.skip": "Skip", "onboarding.skip": "Skip",
"search_popout.search_format": "Advanced search format",
"search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
"search_popout.tips.hashtag": "hashtag",
"search_popout.tips.status": "status",
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
"search_popout.tips.user": "user",
"settings.always_show_spoilers_field": "Always enable the Content Warning field", "settings.always_show_spoilers_field": "Always enable the Content Warning field",
"settings.auto_collapse": "Automatic collapsing", "settings.auto_collapse": "Automatic collapsing",
"settings.auto_collapse_all": "Everything", "settings.auto_collapse_all": "Everything",

View File

@ -1,4 +1,4 @@
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
import { import {
COMPOSE_MENTION, COMPOSE_MENTION,
@ -13,6 +13,8 @@ import {
SEARCH_FETCH_SUCCESS, SEARCH_FETCH_SUCCESS,
SEARCH_SHOW, SEARCH_SHOW,
SEARCH_EXPAND_SUCCESS, SEARCH_EXPAND_SUCCESS,
SEARCH_RESULT_CLICK,
SEARCH_RESULT_FORGET,
} from 'flavours/glitch/actions/search'; } from 'flavours/glitch/actions/search';
const initialState = ImmutableMap({ const initialState = ImmutableMap({
@ -22,6 +24,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) {
@ -62,6 +65,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;
} }

View File

@ -99,10 +99,6 @@
} }
} }
.search-popout {
@include search-popout;
}
.navigation-bar { .navigation-bar {
padding: 10px; padding: 10px;
color: $darker-text-color; color: $darker-text-color;

View File

@ -18,6 +18,10 @@
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;

View File

@ -1,6 +1,86 @@
.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: 99;
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 {

View File

@ -0,0 +1,29 @@
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();