M app/javascript/flavours/glitch/actions/search.js => app/javascript/flavours/glitch/actions/search.js +44 -1
@@ 15,6 15,9 @@ 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_RESULT_CLICK = 'SEARCH_RESULT_CLICK';
+export const SEARCH_RESULT_FORGET = 'SEARCH_RESULT_FORGET';
+
export function changeSearch(value) {
return {
type: SEARCH_CHANGE,
@@ 28,7 31,7 @@ export function clearSearch() {
};
}
-export function submitSearch() {
+export function submitSearch(type) {
return (dispatch, getState) => {
const value = getState().getIn(['search', 'value']);
const signedIn = !!getState().getIn(['meta', 'me']);
@@ 45,6 48,7 @@ export function submitSearch() {
q: value,
resolve: signedIn,
limit: 10,
+ type,
},
}).then(response => {
if (response.data.accounts) {
@@ 131,3 135,42 @@ export const expandSearchFail = error => ({
export const showSearch = () => ({
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,
+});
M app/javascript/flavours/glitch/features/compose/components/search.jsx => app/javascript/flavours/glitch/features/compose/components/search.jsx +234 -52
@@ 7,39 7,21 @@ import {
defineMessages,
} 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 { searchEnabled } from 'flavours/glitch/initial_state';
import { focusRoot } from 'flavours/glitch/utils/dom_helpers';
+import { HASHTAG_REGEX } from 'flavours/glitch/utils/hashtags';
const messages = defineMessages({
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
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.
class Search extends PureComponent {
@@ 50,9 32,13 @@ class Search extends PureComponent {
static propTypes = {
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,
@@ 62,59 48,104 @@ class Search extends PureComponent {
state = {
expanded: false,
+ selectedOption: -1,
+ options: [],
};
setRef = c => {
this.searchForm = c;
};
- handleChange = (e) => {
+ handleChange = ({ target }) => {
const { onChange } = this.props;
- if (onChange) {
- onChange(e.target.value);
- }
+
+ onChange(target.value);
+
+ this._calculateOptions(target.value);
};
- handleClear = (e) => {
+ handleClear = e => {
const {
onClear,
submitted,
value,
} = this.props;
+
e.preventDefault(); // Prevents focus change ??
- if (onClear && (submitted || value && value.length)) {
+
+ if (value.length > 0 || submitted) {
onClear();
+ this.setState({ options: [], selectedOption: -1 })
}
};
handleBlur = () => {
- this.setState({ expanded: false });
+ this.setState({ expanded: false, selectedOption: -1 });
};
handleFocus = () => {
- this.setState({ expanded: true });
- this.props.onShow();
+ const { onShow, singleColumn } = this.props;
+
+ this.setState({ expanded: true, selectedOption: -1 });
+ onShow();
- if (this.searchForm && !this.props.singleColumn) {
+ if (this.searchForm && !singleColumn) {
const { left, right } = this.searchForm.getBoundingClientRect();
+
if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) {
this.searchForm.scrollIntoView();
}
}
};
- handleKeyUp = (e) => {
- const { onSubmit } = this.props;
- switch (e.key) {
+ handleKeyDown = (e) => {
+ const { selectedOption } = this.state;
+ const options = this._getOptions();
+
+ switch(e.key) {
+ 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':
- onSubmit();
+ e.preventDefault();
- if (this.props.openInRoute) {
- this.context.router.history.push('/search');
+ 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;
- case 'Escape':
- focusRoot();
}
};
@@ 122,14 153,141 @@ class Search extends PureComponent {
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 () {
- const { intl, value, submitted } = this.props;
- const { expanded } = this.state;
+ const { intl, value, submitted, recent } = this.props;
+ const { expanded, options, selectedOption } = this.state;
const { signedIn } = this.context.identity;
+
const hasValue = value.length > 0 || submitted;
return (
- <div className='search'>
+ <div className={classNames('search', { active: expanded })}>
<input
ref={this.setRef}
className='search__input'
@@ 138,7 296,7 @@ class Search extends PureComponent {
aria-label={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
value={value || ''}
onChange={this.handleChange}
- onKeyUp={this.handleKeyUp}
+ onKeyDown={this.handleKeyDown}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
/>
@@ 147,15 305,39 @@ class Search extends PureComponent {
<Icon id='search' className={hasValue ? '' : 'active'} />
<Icon id='times-circle' className={hasValue ? 'active' : ''} />
</div>
- <Overlay show={expanded && !hasValue} placement='bottom' target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
- {({ props, placement }) => (
- <div {...props} style={{ ...props.style, width: 285, zIndex: 2 }}>
- <div className={`dropdown-animation ${placement}`}>
- <SearchPopout />
+ <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, 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>
+ </>
+ )}
+ {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>
+ </>
)}
- </Overlay>
+ </div>
</div>
);
}
M app/javascript/flavours/glitch/features/compose/components/search_results.jsx => app/javascript/flavours/glitch/features/compose/components/search_results.jsx +1 -1
@@ 89,7 89,7 @@ class SearchResults extends ImmutablePureComponent {
count += results.get('accounts').size;
accounts = (
<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} />)}
M app/javascript/flavours/glitch/features/compose/containers/search_container.js => app/javascript/flavours/glitch/features/compose/containers/search_container.js +18 -2
@@ 5,6 5,9 @@ import {
clearSearch,
submitSearch,
showSearch,
+ openURL,
+ clickSearchResult,
+ forgetSearchResult,
} from 'flavours/glitch/actions/search';
import Search from '../components/search';
@@ 12,6 15,7 @@ import Search from '../components/search';
const mapStateToProps = state => ({
value: state.getIn(['search', 'value']),
submitted: state.getIn(['search', 'submitted']),
+ recent: state.getIn(['search', 'recent']),
});
const mapDispatchToProps = dispatch => ({
@@ 24,14 28,26 @@ const mapDispatchToProps = dispatch => ({
dispatch(clearSearch());
},
- onSubmit () {
- dispatch(submitSearch());
+ onSubmit (type) {
+ dispatch(submitSearch(type));
},
onShow () {
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);
M app/javascript/flavours/glitch/features/compose/containers/warning_container.jsx => app/javascript/flavours/glitch/features/compose/containers/warning_container.jsx +2 -25
@@ 6,37 6,14 @@ import { connect } from 'react-redux';
import { me } from 'flavours/glitch/initial_state';
import { profileLink, privacyPolicyLink } from 'flavours/glitch/utils/backend_links';
+import { HASHTAG_PATTERN_REGEX } from 'flavours/glitch/utils/hashtags';
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 => ({
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',
});
M app/javascript/flavours/glitch/features/explore/results.jsx => app/javascript/flavours/glitch/features/explore/results.jsx +1 -1
@@ 111,7 111,7 @@ class Results extends PureComponent {
<>
<div className='account__section-headline'>
<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.handleSelectStatuses} className={type === 'statuses' && 'active'}><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></button>
</div>
M app/javascript/flavours/glitch/locales/en.json => app/javascript/flavours/glitch/locales/en.json +0 -6
@@ 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_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",
- "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.auto_collapse": "Automatic collapsing",
"settings.auto_collapse_all": "Everything",
M app/javascript/flavours/glitch/reducers/search.js => app/javascript/flavours/glitch/reducers/search.js +8 -1
@@ 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 {
COMPOSE_MENTION,
@@ 13,6 13,8 @@ import {
SEARCH_FETCH_SUCCESS,
SEARCH_SHOW,
SEARCH_EXPAND_SUCCESS,
+ SEARCH_RESULT_CLICK,
+ SEARCH_RESULT_FORGET,
} from 'flavours/glitch/actions/search';
const initialState = ImmutableMap({
@@ 22,6 24,7 @@ const initialState = ImmutableMap({
results: ImmutableMap(),
isLoading: false,
searchTerm: '',
+ recent: ImmutableOrderedSet(),
});
export default function search(state = initialState, action) {
@@ 62,6 65,10 @@ export default function search(state = initialState, action) {
case SEARCH_EXPAND_SUCCESS:
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));
+ 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:
return state;
}
M app/javascript/flavours/glitch/styles/components/drawer.scss => app/javascript/flavours/glitch/styles/components/drawer.scss +0 -4
@@ 99,10 99,6 @@
}
}
-.search-popout {
- @include search-popout;
-}
-
.navigation-bar {
padding: 10px;
color: $darker-text-color;
M app/javascript/flavours/glitch/styles/components/explore.scss => app/javascript/flavours/glitch/styles/components/explore.scss +4 -0
@@ 18,6 18,10 @@
padding: 10px;
}
+ .search__popout {
+ border: 1px solid lighten($ui-base-color, 8%);
+ }
+
.search .fa {
top: 10px;
inset-inline-end: 10px;
M app/javascript/flavours/glitch/styles/components/search.scss => app/javascript/flavours/glitch/styles/components/search.scss +80 -0
@@ 1,6 1,86 @@
.search {
margin-bottom: 10px;
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 {
A app/javascript/flavours/glitch/utils/hashtags.ts => app/javascript/flavours/glitch/utils/hashtags.ts +29 -0
@@ 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();