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