2017-04-22 03:05:35 +09:00
import PropTypes from 'prop-types' ;
2023-05-24 00:15:17 +09:00
import { PureComponent } from 'react' ;
2023-08-28 20:18:39 +09:00
import { defineMessages , injectIntl , FormattedMessage , FormattedList } from 'react-intl' ;
2023-05-24 00:15:17 +09:00
2023-04-01 16:59:10 +09:00
import classNames from 'classnames' ;
2023-05-24 00:15:17 +09:00
import ImmutablePropTypes from 'react-immutable-proptypes' ;
import { Icon } from 'mastodon/components/icon' ;
2023-09-07 00:23:58 +09:00
import { domain , searchEnabled } from 'mastodon/initial_state' ;
2023-04-01 16:59:10 +09:00
import { HASHTAG _REGEX } from 'mastodon/utils/hashtags' ;
2016-11-18 23:36:16 +09:00
const messages = defineMessages ( {
2017-05-21 00:31:47 +09:00
placeholder : { id : 'search.placeholder' , defaultMessage : 'Search' } ,
2022-10-29 20:32:49 +09:00
placeholderSignedIn : { id : 'search.search_or_paste' , defaultMessage : 'Search or paste URL' } ,
2016-11-18 23:36:16 +09:00
} ) ;
2016-11-13 21:04:18 +09:00
2023-05-23 17:52:27 +09:00
class Search extends PureComponent {
2016-11-13 21:04:18 +09:00
2019-05-26 04:27:00 +09:00
static contextTypes = {
router : PropTypes . object . isRequired ,
2022-10-29 20:32:49 +09:00
identity : PropTypes . object . isRequired ,
2019-05-26 04:27:00 +09:00
} ;
2017-05-12 21:44:10 +09:00
static propTypes = {
value : PropTypes . string . isRequired ,
2023-04-01 16:59:10 +09:00
recent : ImmutablePropTypes . orderedSet ,
2017-05-12 21:44:10 +09:00
submitted : PropTypes . bool ,
onChange : PropTypes . func . isRequired ,
onSubmit : PropTypes . func . isRequired ,
2023-04-01 16:59:10 +09:00
onOpenURL : PropTypes . func . isRequired ,
onClickSearchResult : PropTypes . func . isRequired ,
onForgetSearchResult : PropTypes . func . isRequired ,
2017-05-12 21:44:10 +09:00
onClear : PropTypes . func . isRequired ,
onShow : PropTypes . func . isRequired ,
2019-05-26 04:27:00 +09:00
openInRoute : PropTypes . bool ,
2017-05-21 00:31:47 +09:00
intl : PropTypes . object . isRequired ,
2019-10-02 02:19:10 +09:00
singleColumn : PropTypes . bool ,
2017-05-12 21:44:10 +09:00
} ;
2017-10-03 01:24:05 +09:00
state = {
expanded : false ,
2023-04-01 16:59:10 +09:00
selectedOption : - 1 ,
options : [ ] ,
2017-10-03 01:24:05 +09:00
} ;
2023-08-28 20:18:39 +09:00
defaultOptions = [
{ label : < > < mark > has : < / mark > < FormattedList type = 'disjunction' value = { [ 'media' , 'poll' , 'embed' ] } / > < / > , action : e => { e . preventDefault ( ) ; this . _insertText ( 'has:' ) } } ,
{ label : < > < mark > is : < / mark > < FormattedList type = 'disjunction' value = { [ 'reply' , 'sensitive' ] } / > < / > , action : e => { e . preventDefault ( ) ; this . _insertText ( 'is:' ) } } ,
{ label : < > < mark > language : < / mark > < FormattedMessage id = 'search_popout.language_code' defaultMessage = 'ISO language code' / > < / > , action : e => { e . preventDefault ( ) ; this . _insertText ( 'language:' ) } } ,
{ label : < > < mark > from : < / mark > < FormattedMessage id = 'search_popout.user' defaultMessage = 'user' / > < / > , action : e => { e . preventDefault ( ) ; this . _insertText ( 'from:' ) } } ,
{ label : < > < mark > before : < / mark > < FormattedMessage id = 'search_popout.specific_date' defaultMessage = 'specific date' / > < / > , action : e => { e . preventDefault ( ) ; this . _insertText ( 'before:' ) } } ,
{ label : < > < mark > during : < / mark > < FormattedMessage id = 'search_popout.specific_date' defaultMessage = 'specific date' / > < / > , action : e => { e . preventDefault ( ) ; this . _insertText ( 'during:' ) } } ,
{ label : < > < mark > after : < / mark > < FormattedMessage id = 'search_popout.specific_date' defaultMessage = 'specific date' / > < / > , action : e => { e . preventDefault ( ) ; this . _insertText ( 'after:' ) } } ,
2023-09-06 13:46:26 +09:00
{ label : < > < mark > in : < / mark > < FormattedList type = 'disjunction' value = { [ 'all' , 'library' ] } / > < / > , action : e => { e . preventDefault ( ) ; this . _insertText ( 'in:' ) } }
2023-08-28 20:18:39 +09:00
] ;
2019-10-02 02:19:10 +09:00
setRef = c => {
this . searchForm = c ;
2023-01-30 09:45:35 +09:00
} ;
2019-10-02 02:19:10 +09:00
2023-04-01 16:59:10 +09:00
handleChange = ( { target } ) => {
const { onChange } = this . props ;
onChange ( target . value ) ;
this . _calculateOptions ( target . value ) ;
2023-01-30 09:45:35 +09:00
} ;
2016-11-13 21:04:18 +09:00
2023-04-01 16:59:10 +09:00
handleClear = e => {
const { value , submitted , onClear } = this . props ;
2017-04-01 02:59:54 +09:00
e . preventDefault ( ) ;
2017-04-23 11:39:50 +09:00
2023-04-01 16:59:10 +09:00
if ( value . length > 0 || submitted ) {
onClear ( ) ;
this . setState ( { options : [ ] , selectedOption : - 1 } ) ;
2017-04-23 11:39:50 +09:00
}
2023-01-30 09:45:35 +09:00
} ;
2016-11-13 21:04:18 +09:00
2023-04-01 16:59:10 +09:00
handleKeyDown = ( e ) => {
const { selectedOption } = this . state ;
2023-09-01 22:13:27 +09:00
const options = searchEnabled ? this . _getOptions ( ) . concat ( this . defaultOptions ) : this . _getOptions ( ) ;
2023-04-01 16:59:10 +09:00
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' :
2017-04-01 02:59:54 +09:00
e . preventDefault ( ) ;
2019-05-26 04:27:00 +09:00
2023-04-01 16:59:10 +09:00
if ( selectedOption === - 1 ) {
this . _submit ( ) ;
} else if ( options . length > 0 ) {
2023-08-28 20:18:39 +09:00
options [ selectedOption ] . action ( e ) ;
2023-04-01 16:59:10 +09:00
}
break ;
case 'Delete' :
if ( selectedOption > - 1 && options . length > 0 ) {
const search = options [ selectedOption ] ;
if ( typeof search . forget === 'function' ) {
e . preventDefault ( ) ;
search . forget ( e ) ;
}
2019-05-26 04:27:00 +09:00
}
2023-04-01 16:59:10 +09:00
break ;
2017-04-01 02:59:54 +09:00
}
2023-01-30 09:45:35 +09:00
} ;
2016-11-13 21:04:18 +09:00
2017-05-12 21:44:10 +09:00
handleFocus = ( ) => {
2023-04-01 16:59:10 +09:00
const { onShow , singleColumn } = this . props ;
this . setState ( { expanded : true , selectedOption : - 1 } ) ;
onShow ( ) ;
2019-10-02 02:19:10 +09:00
2023-04-01 16:59:10 +09:00
if ( this . searchForm && ! singleColumn ) {
2019-10-02 02:19:10 +09:00
const { left , right } = this . searchForm . getBoundingClientRect ( ) ;
2023-04-01 16:59:10 +09:00
2019-10-02 02:19:10 +09:00
if ( left < 0 || right > ( window . innerWidth || document . documentElement . clientWidth ) ) {
this . searchForm . scrollIntoView ( ) ;
}
}
2023-01-30 09:45:35 +09:00
} ;
2016-11-13 21:04:18 +09:00
2017-10-03 01:24:05 +09:00
handleBlur = ( ) => {
2023-04-01 16:59:10 +09:00
this . setState ( { expanded : false , selectedOption : - 1 } ) ;
2023-01-30 09:45:35 +09:00
} ;
2017-10-03 01:24:05 +09:00
2023-04-01 16:59:10 +09:00
handleHashtagClick = ( ) => {
const { router } = this . context ;
const { value , onClickSearchResult } = this . props ;
const query = value . trim ( ) . replace ( /^#/ , '' ) ;
router . history . push ( ` /tags/ ${ query } ` ) ;
onClickSearchResult ( query , 'hashtag' ) ;
2023-08-28 20:18:39 +09:00
this . _unfocus ( ) ;
2023-04-01 16:59:10 +09:00
} ;
handleAccountClick = ( ) => {
const { router } = this . context ;
const { value , onClickSearchResult } = this . props ;
const query = value . trim ( ) . replace ( /^@/ , '' ) ;
router . history . push ( ` /@ ${ query } ` ) ;
onClickSearchResult ( query , 'account' ) ;
2023-08-28 20:18:39 +09:00
this . _unfocus ( ) ;
2023-04-01 16:59:10 +09:00
} ;
handleURLClick = ( ) => {
const { router } = this . context ;
2023-04-25 13:33:21 +09:00
const { value , onOpenURL } = this . props ;
2023-04-01 16:59:10 +09:00
2023-04-25 13:33:21 +09:00
onOpenURL ( value , router . history ) ;
2023-08-28 20:18:39 +09:00
this . _unfocus ( ) ;
2023-04-01 16:59:10 +09:00
} ;
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' ) } ` ) ;
}
2023-08-28 20:18:39 +09:00
this . _unfocus ( ) ;
2023-04-01 16:59:10 +09:00
} ;
handleForgetRecentSearchClick = search => {
const { onForgetSearchResult } = this . props ;
onForgetSearchResult ( search . get ( 'q' ) ) ;
} ;
_unfocus ( ) {
document . querySelector ( '.ui' ) . parentElement . focus ( ) ;
}
2023-08-28 20:18:39 +09:00
_insertText ( text ) {
const { value , onChange } = this . props ;
if ( value === '' ) {
onChange ( text ) ;
} else if ( value [ value . length - 1 ] === ' ' ) {
onChange ( ` ${ value } ${ text } ` ) ;
} else {
onChange ( ` ${ value } ${ text } ` ) ;
}
}
2023-04-01 16:59:10 +09:00
_submit ( type ) {
const { onSubmit , openInRoute } = this . props ;
const { router } = this . context ;
onSubmit ( type ) ;
if ( openInRoute ) {
router . history . push ( '/search' ) ;
}
2023-08-28 20:18:39 +09:00
this . _unfocus ( ) ;
2023-04-01 16:59:10 +09:00
}
_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 } ) ;
}
2016-11-13 21:04:18 +09:00
render ( ) {
2023-04-01 16:59:10 +09:00
const { intl , value , submitted , recent } = this . props ;
const { expanded , options , selectedOption } = this . state ;
2022-10-29 20:32:49 +09:00
const { signedIn } = this . context . identity ;
2023-04-01 16:59:10 +09:00
2017-04-01 05:44:12 +09:00
const hasValue = value . length > 0 || submitted ;
2016-11-13 21:04:18 +09:00
return (
2023-04-01 16:59:10 +09:00
< div className = { classNames ( 'search' , { active : expanded } ) } >
2022-12-16 00:20:21 +09:00
< 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 }
2023-04-01 16:59:10 +09:00
onKeyDown = { this . handleKeyDown }
2022-12-16 00:20:21 +09:00
onFocus = { this . handleFocus }
onBlur = { this . handleBlur }
/ >
2016-11-13 21:04:18 +09:00
2023-04-04 23:33:44 +09:00
< div role = 'button' tabIndex = { 0 } className = 'search__icon' onClick = { this . handleClear } >
2019-02-01 08:14:05 +09:00
< Icon id = 'search' className = { hasValue ? '' : 'active' } / >
< Icon id = 'times-circle' className = { hasValue ? 'active' : '' } aria - label = { intl . formatMessage ( messages . placeholder ) } / >
2017-04-01 02:59:54 +09:00
< / div >
2023-04-01 16:59:10 +09:00
< 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 >
) }
2023-01-12 05:58:46 +09:00
< / div >
2023-04-01 16:59:10 +09:00
< / >
2023-01-12 05:58:46 +09:00
) }
2023-04-01 16:59:10 +09:00
{ 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 >
< / >
) }
2023-08-28 20:18:39 +09:00
2023-09-07 00:23:58 +09:00
< h4 > < FormattedMessage id = 'search_popout.options' defaultMessage = 'Search options' / > < / h4 >
{ searchEnabled ? (
< 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 + i ) } ) } >
{ label }
< / button >
) ) }
< / div >
) : (
< div className = 'search__popout__menu__message' >
< FormattedMessage id = 'search_popout.full_text_search_disabled_message' defaultMessage = 'Not available on {domain}.' values = { { domain } } / >
< / div >
2023-09-01 22:13:27 +09:00
) }
2023-04-01 16:59:10 +09:00
< / div >
2016-11-13 21:04:18 +09:00
< / div >
) ;
2017-04-01 02:59:54 +09:00
}
2016-11-13 21:04:18 +09:00
2017-04-22 03:05:35 +09:00
}
2023-03-24 11:17:53 +09:00
export default injectIntl ( Search ) ;