2017-04-21 19:05:35 +01:00
import PropTypes from 'prop-types' ;
2023-05-23 16:15:17 +01:00
import { PureComponent } from 'react' ;
2017-10-02 17:24:05 +01:00
import { defineMessages , injectIntl , FormattedMessage } from 'react-intl' ;
2023-05-23 16:15:17 +01:00
2023-04-01 08:59:10 +01:00
import classNames from 'classnames' ;
2023-05-23 16:15:17 +01:00
import ImmutablePropTypes from 'react-immutable-proptypes' ;
import { Icon } from 'mastodon/components/icon' ;
import { searchEnabled } from 'mastodon/initial_state' ;
2023-04-01 08:59:10 +01:00
import { HASHTAG _REGEX } from 'mastodon/utils/hashtags' ;
2016-11-18 14:36:16 +00:00
const messages = defineMessages ( {
2017-05-20 16:31:47 +01:00
placeholder : { id : 'search.placeholder' , defaultMessage : 'Search' } ,
2022-10-29 12:32:49 +01:00
placeholderSignedIn : { id : 'search.search_or_paste' , defaultMessage : 'Search or paste URL' } ,
2016-11-18 14:36:16 +00:00
} ) ;
2016-11-13 12:04:18 +00:00
2023-05-23 09:52:27 +01:00
class Search extends PureComponent {
2016-11-13 12:04:18 +00:00
2019-05-25 20:27:00 +01:00
static contextTypes = {
router : PropTypes . object . isRequired ,
2022-10-29 12:32:49 +01:00
identity : PropTypes . object . isRequired ,
2019-05-25 20:27:00 +01:00
} ;
2017-05-12 13:44:10 +01:00
static propTypes = {
value : PropTypes . string . isRequired ,
2023-04-01 08:59:10 +01:00
recent : ImmutablePropTypes . orderedSet ,
2017-05-12 13:44:10 +01:00
submitted : PropTypes . bool ,
onChange : PropTypes . func . isRequired ,
onSubmit : PropTypes . func . isRequired ,
2023-04-01 08:59:10 +01:00
onOpenURL : PropTypes . func . isRequired ,
onClickSearchResult : PropTypes . func . isRequired ,
onForgetSearchResult : PropTypes . func . isRequired ,
2017-05-12 13:44:10 +01:00
onClear : PropTypes . func . isRequired ,
onShow : PropTypes . func . isRequired ,
2019-05-25 20:27:00 +01:00
openInRoute : PropTypes . bool ,
2017-05-20 16:31:47 +01:00
intl : PropTypes . object . isRequired ,
2019-10-01 18:19:10 +01:00
singleColumn : PropTypes . bool ,
2017-05-12 13:44:10 +01:00
} ;
2017-10-02 17:24:05 +01:00
state = {
expanded : false ,
2023-04-01 08:59:10 +01:00
selectedOption : - 1 ,
options : [ ] ,
2017-10-02 17:24:05 +01:00
} ;
2019-10-01 18:19:10 +01:00
setRef = c => {
this . searchForm = c ;
2023-01-30 00:45:35 +00:00
} ;
2019-10-01 18:19:10 +01:00
2023-04-01 08:59:10 +01:00
handleChange = ( { target } ) => {
const { onChange } = this . props ;
onChange ( target . value ) ;
this . _calculateOptions ( target . value ) ;
2023-01-30 00:45:35 +00:00
} ;
2016-11-13 12:04:18 +00:00
2023-04-01 08:59:10 +01:00
handleClear = e => {
const { value , submitted , onClear } = this . props ;
2017-03-31 18:59:54 +01:00
e . preventDefault ( ) ;
2017-04-23 03:39:50 +01:00
2023-04-01 08:59:10 +01:00
if ( value . length > 0 || submitted ) {
onClear ( ) ;
this . setState ( { options : [ ] , selectedOption : - 1 } ) ;
2017-04-23 03:39:50 +01:00
}
2023-01-30 00:45:35 +00:00
} ;
2016-11-13 12:04:18 +00:00
2023-04-01 08:59:10 +01:00
handleKeyDown = ( e ) => {
const { selectedOption } = this . state ;
const options = this . _getOptions ( ) ;
switch ( e . key ) {
case 'Escape' :
e . preventDefault ( ) ;
this . _unfocus ( ) ;
break ;
case 'ArrowDown' :
e . preventDefault ( ) ;
if ( options . length > 0 ) {
this . setState ( { selectedOption : Math . min ( selectedOption + 1 , options . length - 1 ) } ) ;
}
break ;
case 'ArrowUp' :
e . preventDefault ( ) ;
if ( options . length > 0 ) {
this . setState ( { selectedOption : Math . max ( selectedOption - 1 , - 1 ) } ) ;
}
break ;
case 'Enter' :
2017-03-31 18:59:54 +01:00
e . preventDefault ( ) ;
2019-05-25 20:27:00 +01:00
2023-04-01 08:59:10 +01:00
if ( selectedOption === - 1 ) {
this . _submit ( ) ;
} else if ( options . length > 0 ) {
options [ selectedOption ] . action ( ) ;
}
this . _unfocus ( ) ;
2019-05-25 20:27:00 +01:00
2023-04-01 08:59:10 +01: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-25 20:27:00 +01:00
}
2023-04-01 08:59:10 +01:00
break ;
2017-03-31 18:59:54 +01:00
}
2023-01-30 00:45:35 +00:00
} ;
2016-11-13 12:04:18 +00:00
2017-05-12 13:44:10 +01:00
handleFocus = ( ) => {
2023-04-01 08:59:10 +01:00
const { onShow , singleColumn } = this . props ;
this . setState ( { expanded : true , selectedOption : - 1 } ) ;
onShow ( ) ;
2019-10-01 18:19:10 +01:00
2023-04-01 08:59:10 +01:00
if ( this . searchForm && ! singleColumn ) {
2019-10-01 18:19:10 +01:00
const { left , right } = this . searchForm . getBoundingClientRect ( ) ;
2023-04-01 08:59:10 +01:00
2019-10-01 18:19:10 +01:00
if ( left < 0 || right > ( window . innerWidth || document . documentElement . clientWidth ) ) {
this . searchForm . scrollIntoView ( ) ;
}
}
2023-01-30 00:45:35 +00:00
} ;
2016-11-13 12:04:18 +00:00
2017-10-02 17:24:05 +01:00
handleBlur = ( ) => {
2023-04-01 08:59:10 +01:00
this . setState ( { expanded : false , selectedOption : - 1 } ) ;
2023-01-30 00:45:35 +00:00
} ;
2017-10-02 17:24:05 +01:00
2023-01-11 20:58:46 +00:00
findTarget = ( ) => {
return this . searchForm ;
2023-01-30 00:45:35 +00:00
} ;
2023-01-11 20:58:46 +00:00
2023-04-01 08:59:10 +01:00
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 ;
2023-04-25 05:33:21 +01:00
const { value , onOpenURL } = this . props ;
2023-04-01 08:59:10 +01:00
2023-04-25 05:33:21 +01:00
onOpenURL ( value , router . history ) ;
2023-04-01 08:59:10 +01: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' ) } ` ) ;
}
} ;
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 } ) ;
}
2016-11-13 12:04:18 +00:00
render ( ) {
2023-04-01 08:59:10 +01:00
const { intl , value , submitted , recent } = this . props ;
const { expanded , options , selectedOption } = this . state ;
2022-10-29 12:32:49 +01:00
const { signedIn } = this . context . identity ;
2023-04-01 08:59:10 +01:00
2017-03-31 21:44:12 +01:00
const hasValue = value . length > 0 || submitted ;
2016-11-13 12:04:18 +00:00
return (
2023-04-01 08:59:10 +01:00
< div className = { classNames ( 'search' , { active : expanded } ) } >
2022-12-15 15:20:21 +00: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 08:59:10 +01:00
onKeyDown = { this . handleKeyDown }
2022-12-15 15:20:21 +00:00
onFocus = { this . handleFocus }
onBlur = { this . handleBlur }
/ >
2016-11-13 12:04:18 +00:00
2023-04-04 15:33:44 +01:00
< div role = 'button' tabIndex = { 0 } className = 'search__icon' onClick = { this . handleClear } >
2019-01-31 23:14:05 +00:00
< Icon id = 'search' className = { hasValue ? '' : 'active' } / >
< Icon id = 'times-circle' className = { hasValue ? 'active' : '' } aria - label = { intl . formatMessage ( messages . placeholder ) } / >
2017-03-31 18:59:54 +01:00
< / div >
2023-04-01 08:59:10 +01: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-11 20:58:46 +00:00
< / div >
2023-04-01 08:59:10 +01:00
< / >
2023-01-11 20:58:46 +00:00
) }
2023-04-01 08:59:10 +01: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 >
< / >
) }
< / div >
2016-11-13 12:04:18 +00:00
< / div >
) ;
2017-03-31 18:59:54 +01:00
}
2016-11-13 12:04:18 +00:00
2017-04-21 19:05:35 +01:00
}
2023-03-24 02:17:53 +00:00
export default injectIntl ( Search ) ;