diff --git a/FEDERATION.md b/FEDERATION.md index cd1957cbd1..e3721d7241 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -27,4 +27,5 @@ More information on HTTP Signatures, as well as examples, can be found here: htt - Linked-Data Signatures: https://docs.joinmastodon.org/spec/security/#ld - Bearcaps: https://docs.joinmastodon.org/spec/bearcaps/ -- Followers collection synchronization: https://git.activitypub.dev/ActivityPubDev/Fediverse-Enhancement-Proposals/src/branch/main/feps/fep-8fcf.md +- Followers collection synchronization: https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md +- Search indexing consent for actors: https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md diff --git a/Gemfile.lock b/Gemfile.lock index 642be3f9ac..4ba0a09946 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -520,7 +520,7 @@ GEM pastel (0.8.0) tty-color (~> 0.5) pg (1.5.4) - pghero (3.3.3) + pghero (3.3.4) activerecord (>= 6) posix-spawn (0.3.15) premailer (1.21.0) diff --git a/SECURITY.md b/SECURITY.md index 7a79d9f91d..9a08c4e251 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,9 +13,9 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through ## Supported Versions -| Version | Supported | -| ------- | --------- | -| 4.1.x | Yes | -| 4.0.x | Yes | -| 3.5.x | Yes | -| < 3.5 | No | +| Version | Supported | +| ------- | ---------------- | +| 4.1.x | Yes | +| 4.0.x | Until 2023-10-31 | +| 3.5.x | Until 2023-12-31 | +| < 3.5 | No | diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index b0c4fff8bc..f0a344f1c9 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -119,7 +119,7 @@ module SignatureVerification private def fail_with!(message, **options) - Rails.logger.warn { "Signature verification failed: #{message}" } + Rails.logger.debug { "Signature verification failed: #{message}" } @signature_verification_failure_reason = { error: message }.merge(options) @signed_request_actor = nil diff --git a/app/javascript/mastodon/actions/search.js b/app/javascript/mastodon/actions/search.js index 94e7f2ed75..21fd540768 100644 --- a/app/javascript/mastodon/actions/search.js +++ b/app/javascript/mastodon/actions/search.js @@ -37,17 +37,17 @@ export function submitSearch(type) { const signedIn = !!getState().getIn(['meta', 'me']); if (value.length === 0) { - dispatch(fetchSearchSuccess({ accounts: [], statuses: [], hashtags: [] }, '')); + dispatch(fetchSearchSuccess({ accounts: [], statuses: [], hashtags: [] }, '', type)); return; } - dispatch(fetchSearchRequest()); + dispatch(fetchSearchRequest(type)); api(getState).get('/api/v2/search', { params: { q: value, resolve: signedIn, - limit: 5, + limit: 11, type, }, }).then(response => { @@ -59,7 +59,7 @@ export function submitSearch(type) { dispatch(importFetchedStatuses(response.data.statuses)); } - dispatch(fetchSearchSuccess(response.data, value)); + dispatch(fetchSearchSuccess(response.data, value, type)); dispatch(fetchRelationships(response.data.accounts.map(item => item.id))); }).catch(error => { dispatch(fetchSearchFail(error)); @@ -67,16 +67,18 @@ export function submitSearch(type) { }; } -export function fetchSearchRequest() { +export function fetchSearchRequest(searchType) { return { type: SEARCH_FETCH_REQUEST, + searchType, }; } -export function fetchSearchSuccess(results, searchTerm) { +export function fetchSearchSuccess(results, searchTerm, searchType) { return { type: SEARCH_FETCH_SUCCESS, results, + searchType, searchTerm, }; } @@ -90,15 +92,16 @@ export function fetchSearchFail(error) { export const expandSearch = type => (dispatch, getState) => { const value = getState().getIn(['search', 'value']); - const offset = getState().getIn(['search', 'results', type]).size; + const offset = getState().getIn(['search', 'results', type]).size - 1; - dispatch(expandSearchRequest()); + dispatch(expandSearchRequest(type)); api(getState).get('/api/v2/search', { params: { q: value, type, offset, + limit: 11, }, }).then(({ data }) => { if (data.accounts) { @@ -116,8 +119,9 @@ export const expandSearch = type => (dispatch, getState) => { }); }; -export const expandSearchRequest = () => ({ +export const expandSearchRequest = (searchType) => ({ type: SEARCH_EXPAND_REQUEST, + searchType, }); export const expandSearchSuccess = (results, searchTerm, searchType) => ({ diff --git a/app/javascript/mastodon/components/animated_number.tsx b/app/javascript/mastodon/components/animated_number.tsx index 05a7e01898..e98e30b242 100644 --- a/app/javascript/mastodon/components/animated_number.tsx +++ b/app/javascript/mastodon/components/animated_number.tsx @@ -6,21 +6,10 @@ import { reduceMotion } from '../initial_state'; import { ShortNumber } from './short_number'; -const obfuscatedCount = (count: number) => { - if (count < 0) { - return 0; - } else if (count <= 1) { - return count; - } else { - return '1+'; - } -}; - interface Props { value: number; - obfuscate?: boolean; } -export const AnimatedNumber: React.FC<Props> = ({ value, obfuscate }) => { +export const AnimatedNumber: React.FC<Props> = ({ value }) => { const [previousValue, setPreviousValue] = useState(value); const [direction, setDirection] = useState<1 | -1>(1); @@ -36,11 +25,7 @@ export const AnimatedNumber: React.FC<Props> = ({ value, obfuscate }) => { ); if (reduceMotion) { - return obfuscate ? ( - <>{obfuscatedCount(value)}</> - ) : ( - <ShortNumber value={value} /> - ); + return <ShortNumber value={value} />; } const styles = [ @@ -67,11 +52,7 @@ export const AnimatedNumber: React.FC<Props> = ({ value, obfuscate }) => { transform: `translateY(${style.y * 100}%)`, }} > - {obfuscate ? ( - obfuscatedCount(data as number) - ) : ( - <ShortNumber value={data as number} /> - )} + <ShortNumber value={data as number} /> </span> ))} </span> diff --git a/app/javascript/mastodon/components/icon_button.tsx b/app/javascript/mastodon/components/icon_button.tsx index 9dbee2cc24..da6f19e9ea 100644 --- a/app/javascript/mastodon/components/icon_button.tsx +++ b/app/javascript/mastodon/components/icon_button.tsx @@ -24,7 +24,6 @@ interface Props { overlay: boolean; tabIndex: number; counter?: number; - obfuscateCount?: boolean; href?: string; ariaHidden: boolean; } @@ -105,7 +104,6 @@ export class IconButton extends PureComponent<Props, States> { tabIndex, title, counter, - obfuscateCount, href, ariaHidden, } = this.props; @@ -131,7 +129,7 @@ export class IconButton extends PureComponent<Props, States> { <Icon id={icon} fixedWidth aria-hidden='true' />{' '} {typeof counter !== 'undefined' && ( <span className='icon-button__counter'> - <AnimatedNumber value={counter} obfuscate={obfuscateCount} /> + <AnimatedNumber value={counter} /> </span> )} </> diff --git a/app/javascript/mastodon/components/status_action_bar.jsx b/app/javascript/mastodon/components/status_action_bar.jsx index 5d441c10c2..13e6dff74d 100644 --- a/app/javascript/mastodon/components/status_action_bar.jsx +++ b/app/javascript/mastodon/components/status_action_bar.jsx @@ -362,7 +362,7 @@ class StatusActionBar extends ImmutablePureComponent { return ( <div className='status__action-bar'> - <IconButton className='status__action-bar__button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount /> + <IconButton className='status__action-bar__button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} /> <IconButton className={classNames('status__action-bar__button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} /> <IconButton className='status__action-bar__button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} /> <IconButton className='status__action-bar__button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /> diff --git a/app/javascript/mastodon/features/compose/components/search.jsx b/app/javascript/mastodon/features/compose/components/search.jsx index 848b812632..b90e9fe060 100644 --- a/app/javascript/mastodon/features/compose/components/search.jsx +++ b/app/javascript/mastodon/features/compose/components/search.jsx @@ -53,6 +53,7 @@ class Search extends PureComponent { { 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:') } }, + { label: <><mark>in:</mark> <FormattedList type='disjunction' value={['all', 'library']} /></>, action: e => { e.preventDefault(); this._insertText('in:') } } ]; setRef = c => { diff --git a/app/javascript/mastodon/features/compose/components/search_results.jsx b/app/javascript/mastodon/features/compose/components/search_results.jsx index b11ac478a4..346d9b18aa 100644 --- a/app/javascript/mastodon/features/compose/components/search_results.jsx +++ b/app/javascript/mastodon/features/compose/components/search_results.jsx @@ -1,46 +1,36 @@ import PropTypes from 'prop-types'; -import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; +import { FormattedMessage } from 'react-intl'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { Icon } from 'mastodon/components/icon'; import { LoadMore } from 'mastodon/components/load_more'; +import { SearchSection } from 'mastodon/features/explore/components/search_section'; import { ImmutableHashtag as Hashtag } from '../../../components/hashtag'; import AccountContainer from '../../../containers/account_container'; import StatusContainer from '../../../containers/status_container'; -import { searchEnabled } from '../../../initial_state'; -const messages = defineMessages({ - dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' }, -}); +const INITIAL_PAGE_LIMIT = 10; + +const withoutLastResult = list => { + if (list.size > INITIAL_PAGE_LIMIT && list.size % INITIAL_PAGE_LIMIT === 1) { + return list.skipLast(1); + } else { + return list; + } +}; class SearchResults extends ImmutablePureComponent { static propTypes = { results: ImmutablePropTypes.map.isRequired, - suggestions: ImmutablePropTypes.list.isRequired, - fetchSuggestions: PropTypes.func.isRequired, expandSearch: PropTypes.func.isRequired, - dismissSuggestion: PropTypes.func.isRequired, searchTerm: PropTypes.string, - intl: PropTypes.object.isRequired, }; - componentDidMount () { - if (this.props.searchTerm === '') { - this.props.fetchSuggestions(); - } - } - - componentDidUpdate () { - if (this.props.searchTerm === '') { - this.props.fetchSuggestions(); - } - } - handleLoadMoreAccounts = () => this.props.expandSearch('accounts'); handleLoadMoreStatuses = () => this.props.expandSearch('statuses'); @@ -48,97 +38,52 @@ class SearchResults extends ImmutablePureComponent { handleLoadMoreHashtags = () => this.props.expandSearch('hashtags'); render () { - const { intl, results, suggestions, dismissSuggestion, searchTerm } = this.props; - - if (searchTerm === '' && !suggestions.isEmpty()) { - return ( - <div className='search-results'> - <div className='trends'> - <div className='trends__header'> - <Icon id='user-plus' fixedWidth /> - <FormattedMessage id='suggestions.header' defaultMessage='You might be interested in…' /> - </div> - - {suggestions && suggestions.map(suggestion => ( - <AccountContainer - key={suggestion.get('account')} - id={suggestion.get('account')} - actionIcon={suggestion.get('source') === 'past_interactions' ? 'times' : null} - actionTitle={suggestion.get('source') === 'past_interactions' ? intl.formatMessage(messages.dismissSuggestion) : null} - onActionClick={dismissSuggestion} - /> - ))} - </div> - </div> - ); - } + const { results } = this.props; let accounts, statuses, hashtags; - let count = 0; if (results.get('accounts') && results.get('accounts').size > 0) { - count += results.get('accounts').size; accounts = ( - <div className='search-results__section'> - <h5><Icon id='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></h5> - - {results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)} - - {results.get('accounts').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreAccounts} />} - </div> - ); - } - - if (results.get('statuses') && results.get('statuses').size > 0) { - count += results.get('statuses').size; - statuses = ( - <div className='search-results__section'> - <h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></h5> - - {results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)} - - {results.get('statuses').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreStatuses} />} - </div> - ); - } else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) { - statuses = ( - <div className='search-results__section'> - <h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></h5> - - <div className='search-results__info'> - <FormattedMessage id='search_results.statuses_fts_disabled' defaultMessage='Searching posts by their content is not enabled on this Mastodon server.' /> - </div> - </div> + <SearchSection title={<><Icon id='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></>}> + {withoutLastResult(results.get('accounts')).map(accountId => <AccountContainer key={accountId} id={accountId} />)} + {(results.get('accounts').size > INITIAL_PAGE_LIMIT && results.get('accounts').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={this.handleLoadMoreAccounts} />} + </SearchSection> ); } if (results.get('hashtags') && results.get('hashtags').size > 0) { - count += results.get('hashtags').size; hashtags = ( - <div className='search-results__section'> - <h5><Icon id='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5> - - {results.get('hashtags').map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)} - - {results.get('hashtags').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreHashtags} />} - </div> + <SearchSection title={<><Icon id='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></>}> + {withoutLastResult(results.get('hashtags')).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)} + {(results.get('hashtags').size > INITIAL_PAGE_LIMIT && results.get('hashtags').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={this.handleLoadMoreHashtags} />} + </SearchSection> ); } + if (results.get('statuses') && results.get('statuses').size > 0) { + statuses = ( + <SearchSection title={<><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></>}> + {withoutLastResult(results.get('statuses')).map(statusId => <StatusContainer key={statusId} id={statusId} />)} + {(results.get('statuses').size > INITIAL_PAGE_LIMIT && results.get('statuses').size % INITIAL_PAGE_LIMIT === 1) && <LoadMore visible onClick={this.handleLoadMoreStatuses} />} + </SearchSection> + ); + } + + return ( <div className='search-results'> <div className='search-results__header'> <Icon id='search' fixedWidth /> - <FormattedMessage id='search_results.total' defaultMessage='{count, plural, one {# result} other {# results}}' values={{ count }} /> + <FormattedMessage id='explore.search_results' defaultMessage='Search results' /> </div> {accounts} - {statuses} {hashtags} + {statuses} </div> ); } } -export default injectIntl(SearchResults); +export default SearchResults; diff --git a/app/javascript/mastodon/features/explore/components/search_section.jsx b/app/javascript/mastodon/features/explore/components/search_section.jsx new file mode 100644 index 0000000000..c84e3f7cef --- /dev/null +++ b/app/javascript/mastodon/features/explore/components/search_section.jsx @@ -0,0 +1,20 @@ +import PropTypes from 'prop-types'; + +import { FormattedMessage } from 'react-intl'; + +export const SearchSection = ({ title, onClickMore, children }) => ( + <div className='search-results__section'> + <div className='search-results__section__header'> + <h3>{title}</h3> + {onClickMore && <button onClick={onClickMore}><FormattedMessage id='search_results.see_all' defaultMessage='See all' /></button>} + </div> + + {children} + </div> +); + +SearchSection.propTypes = { + title: PropTypes.node.isRequired, + onClickMore: PropTypes.func, + children: PropTypes.children, +}; \ No newline at end of file diff --git a/app/javascript/mastodon/features/explore/results.jsx b/app/javascript/mastodon/features/explore/results.jsx index 7d1ce69ad0..1b9d2f30db 100644 --- a/app/javascript/mastodon/features/explore/results.jsx +++ b/app/javascript/mastodon/features/explore/results.jsx @@ -9,13 +9,15 @@ import { List as ImmutableList } from 'immutable'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; -import { expandSearch } from 'mastodon/actions/search'; +import { submitSearch, expandSearch } from 'mastodon/actions/search'; import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag'; -import { LoadMore } from 'mastodon/components/load_more'; -import { LoadingIndicator } from 'mastodon/components/loading_indicator'; +import { Icon } from 'mastodon/components/icon'; +import ScrollableList from 'mastodon/components/scrollable_list'; import Account from 'mastodon/containers/account_container'; import Status from 'mastodon/containers/status_container'; +import { SearchSection } from './components/search_section'; + const messages = defineMessages({ title: { id: 'search_results.title', defaultMessage: 'Search for {q}' }, }); @@ -24,85 +26,175 @@ const mapStateToProps = state => ({ isLoading: state.getIn(['search', 'isLoading']), results: state.getIn(['search', 'results']), q: state.getIn(['search', 'searchTerm']), + submittedType: state.getIn(['search', 'type']), }); -const appendLoadMore = (id, list, onLoadMore) => { - if (list.size >= 5) { - return list.push(<LoadMore key={`${id}-load-more`} visible onClick={onLoadMore} />); +const INITIAL_PAGE_LIMIT = 10; +const INITIAL_DISPLAY = 4; + +const hidePeek = list => { + if (list.size > INITIAL_PAGE_LIMIT && list.size % INITIAL_PAGE_LIMIT === 1) { + return list.skipLast(1); } else { return list; } }; -const renderAccounts = (results, onLoadMore) => appendLoadMore('accounts', results.get('accounts', ImmutableList()).map(item => ( - <Account key={`account-${item}`} id={item} /> -)), onLoadMore); +const renderAccounts = accounts => hidePeek(accounts).map(id => ( + <Account key={id} id={id} /> +)); -const renderHashtags = (results, onLoadMore) => appendLoadMore('hashtags', results.get('hashtags', ImmutableList()).map(item => ( - <Hashtag key={`tag-${item.get('name')}`} hashtag={item} /> -)), onLoadMore); +const renderHashtags = hashtags => hidePeek(hashtags).map(hashtag => ( + <Hashtag key={hashtag.get('name')} hashtag={hashtag} /> +)); -const renderStatuses = (results, onLoadMore) => appendLoadMore('statuses', results.get('statuses', ImmutableList()).map(item => ( - <Status key={`status-${item}`} id={item} /> -)), onLoadMore); +const renderStatuses = statuses => hidePeek(statuses).map(id => ( + <Status key={id} id={id} /> +)); class Results extends PureComponent { static propTypes = { - results: ImmutablePropTypes.map, + results: ImmutablePropTypes.contains({ + accounts: ImmutablePropTypes.orderedSet, + statuses: ImmutablePropTypes.orderedSet, + hashtags: ImmutablePropTypes.orderedSet, + }), isLoading: PropTypes.bool, multiColumn: PropTypes.bool, dispatch: PropTypes.func.isRequired, q: PropTypes.string, intl: PropTypes.object, + submittedType: PropTypes.oneOf(['accounts', 'statuses', 'hashtags']), }; state = { - type: 'all', + type: this.props.submittedType || 'all', }; - handleSelectAll = () => this.setState({ type: 'all' }); - handleSelectAccounts = () => this.setState({ type: 'accounts' }); - handleSelectHashtags = () => this.setState({ type: 'hashtags' }); - handleSelectStatuses = () => this.setState({ type: 'statuses' }); - handleLoadMoreAccounts = () => this.loadMore('accounts'); - handleLoadMoreStatuses = () => this.loadMore('statuses'); - handleLoadMoreHashtags = () => this.loadMore('hashtags'); + static getDerivedStateFromProps(props, state) { + if (props.submittedType !== state.type) { + return { + type: props.submittedType || 'all', + }; + } - loadMore (type) { + return null; + }; + + handleSelectAll = () => { + const { submittedType, dispatch } = this.props; + + // If we originally searched for a specific type, we need to resubmit + // the query to get all types of results + if (submittedType) { + dispatch(submitSearch()); + } + + this.setState({ type: 'all' }); + }; + + handleSelectAccounts = () => { + const { submittedType, dispatch } = this.props; + + // If we originally searched for something else (but not everything), + // we need to resubmit the query for this specific type + if (submittedType !== 'accounts') { + dispatch(submitSearch('accounts')); + } + + this.setState({ type: 'accounts' }); + }; + + handleSelectHashtags = () => { + const { submittedType, dispatch } = this.props; + + // If we originally searched for something else (but not everything), + // we need to resubmit the query for this specific type + if (submittedType !== 'hashtags') { + dispatch(submitSearch('hashtags')); + } + + this.setState({ type: 'hashtags' }); + } + + handleSelectStatuses = () => { + const { submittedType, dispatch } = this.props; + + // If we originally searched for something else (but not everything), + // we need to resubmit the query for this specific type + if (submittedType !== 'statuses') { + dispatch(submitSearch('statuses')); + } + + this.setState({ type: 'statuses' }); + } + + handleLoadMoreAccounts = () => this._loadMore('accounts'); + handleLoadMoreStatuses = () => this._loadMore('statuses'); + handleLoadMoreHashtags = () => this._loadMore('hashtags'); + + _loadMore (type) { const { dispatch } = this.props; dispatch(expandSearch(type)); } + handleLoadMore = () => { + const { type } = this.state; + + if (type !== 'all') { + this._loadMore(type); + } + }; + render () { const { intl, isLoading, q, results } = this.props; const { type } = this.state; - let filteredResults = ImmutableList(); + // We request 1 more result than we display so we can tell if there'd be a next page + const hasMore = type !== 'all' ? results.get(type, ImmutableList()).size > INITIAL_PAGE_LIMIT && results.get(type).size % INITIAL_PAGE_LIMIT === 1 : false; + + let filteredResults; if (!isLoading) { + const accounts = results.get('accounts', ImmutableList()); + const hashtags = results.get('hashtags', ImmutableList()); + const statuses = results.get('statuses', ImmutableList()); + switch(type) { case 'all': - filteredResults = filteredResults.concat(renderAccounts(results, this.handleLoadMoreAccounts), renderHashtags(results, this.handleLoadMoreHashtags), renderStatuses(results, this.handleLoadMoreStatuses)); + filteredResults = (accounts.size + hashtags.size + statuses.size) > 0 ? ( + <> + {accounts.size > 0 && ( + <SearchSection key='accounts' title={<><Icon id='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></>} onClickMore={this.handleLoadMoreAccounts}> + {accounts.take(INITIAL_DISPLAY).map(id => <Account key={id} id={id} />)} + </SearchSection> + )} + + {hashtags.size > 0 && ( + <SearchSection key='hashtags' title={<><Icon id='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></>} onClickMore={this.handleLoadMoreHashtags}> + {hashtags.take(INITIAL_DISPLAY).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)} + </SearchSection> + )} + + {statuses.size > 0 && ( + <SearchSection key='statuses' title={<><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></>} onClickMore={this.handleLoadMoreStatuses}> + {statuses.take(INITIAL_DISPLAY).map(id => <Status key={id} id={id} />)} + </SearchSection> + )} + </> + ) : []; break; case 'accounts': - filteredResults = filteredResults.concat(renderAccounts(results, this.handleLoadMoreAccounts)); + filteredResults = renderAccounts(accounts); break; case 'hashtags': - filteredResults = filteredResults.concat(renderHashtags(results, this.handleLoadMoreHashtags)); + filteredResults = renderHashtags(hashtags); break; case 'statuses': - filteredResults = filteredResults.concat(renderStatuses(results, this.handleLoadMoreStatuses)); + filteredResults = renderStatuses(statuses); break; } - - if (filteredResults.size === 0) { - filteredResults = ( - <div className='empty-column-indicator'> - <FormattedMessage id='search_results.nothing_found' defaultMessage='Could not find anything for these search terms' /> - </div> - ); - } } return ( @@ -115,7 +207,16 @@ class Results extends PureComponent { </div> <div className='explore__search-results'> - {isLoading ? <LoadingIndicator /> : filteredResults} + <ScrollableList + scrollKey='search-results' + isLoading={isLoading} + onLoadMore={this.handleLoadMore} + hasMore={hasMore} + emptyMessage={<FormattedMessage id='search_results.nothing_found' defaultMessage='Could not find anything for these search terms' />} + bindToDocument + > + {filteredResults} + </ScrollableList> </div> <Helmet> diff --git a/app/javascript/mastodon/features/picture_in_picture/components/footer.jsx b/app/javascript/mastodon/features/picture_in_picture/components/footer.jsx index 0f3244b9c0..587f1f7836 100644 --- a/app/javascript/mastodon/features/picture_in_picture/components/footer.jsx +++ b/app/javascript/mastodon/features/picture_in_picture/components/footer.jsx @@ -194,7 +194,7 @@ class Footer extends ImmutablePureComponent { return ( <div className='picture-in-picture__footer'> - <IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount /> + <IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} /> <IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={status.get('reblogs_count')} /> <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} /> {withOpenButton && <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.open)} icon='external-link' onClick={this.handleOpenClick} href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} />} diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 13cddba723..2a99e8ebfd 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -600,10 +600,9 @@ "search_results.all": "All", "search_results.hashtags": "Hashtags", "search_results.nothing_found": "Could not find anything for these search terms", + "search_results.see_all": "See all", "search_results.statuses": "Posts", - "search_results.statuses_fts_disabled": "Searching posts by their content is not enabled on this Mastodon server.", "search_results.title": "Search for {q}", - "search_results.total": "{count, number} {count, plural, one {result} other {results}}", "server_banner.about_active_users": "People using this server during the last 30 days (Monthly Active Users)", "server_banner.active_users": "active users", "server_banner.administered_by": "Administered by:", @@ -675,8 +674,6 @@ "subscribed_languages.lead": "Only posts in selected languages will appear on your home and list timelines after the change. Select none to receive posts in all languages.", "subscribed_languages.save": "Save changes", "subscribed_languages.target": "Change subscribed languages for {target}", - "suggestions.dismiss": "Dismiss suggestion", - "suggestions.header": "You might be interested in…", "tabs_bar.home": "Home", "tabs_bar.notifications": "Notifications", "time_remaining.days": "{number, plural, one {# day} other {# days}} left", diff --git a/app/javascript/mastodon/reducers/search.js b/app/javascript/mastodon/reducers/search.js index ccef314031..c81d7ff3c8 100644 --- a/app/javascript/mastodon/reducers/search.js +++ b/app/javascript/mastodon/reducers/search.js @@ -1,4 +1,4 @@ -import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; +import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; import { COMPOSE_MENTION, @@ -12,6 +12,7 @@ import { SEARCH_FETCH_FAIL, SEARCH_FETCH_SUCCESS, SEARCH_SHOW, + SEARCH_EXPAND_REQUEST, SEARCH_EXPAND_SUCCESS, SEARCH_RESULT_CLICK, SEARCH_RESULT_FORGET, @@ -24,6 +25,7 @@ const initialState = ImmutableMap({ results: ImmutableMap(), isLoading: false, searchTerm: '', + type: null, recent: ImmutableOrderedSet(), }); @@ -37,6 +39,8 @@ export default function search(state = initialState, action) { map.set('results', ImmutableMap()); map.set('submitted', false); map.set('hidden', false); + map.set('searchTerm', ''); + map.set('type', null); }); case SEARCH_SHOW: return state.set('hidden', false); @@ -48,23 +52,27 @@ export default function search(state = initialState, action) { return state.withMutations(map => { map.set('isLoading', true); map.set('submitted', true); + map.set('type', action.searchType); }); case SEARCH_FETCH_FAIL: return state.set('isLoading', false); case SEARCH_FETCH_SUCCESS: return state.withMutations(map => { map.set('results', ImmutableMap({ - accounts: ImmutableList(action.results.accounts.map(item => item.id)), - statuses: ImmutableList(action.results.statuses.map(item => item.id)), - hashtags: fromJS(action.results.hashtags), + accounts: ImmutableOrderedSet(action.results.accounts.map(item => item.id)), + statuses: ImmutableOrderedSet(action.results.statuses.map(item => item.id)), + hashtags: ImmutableOrderedSet(fromJS(action.results.hashtags)), })); map.set('searchTerm', action.searchTerm); + map.set('type', action.searchType); map.set('isLoading', false); }); + case SEARCH_EXPAND_REQUEST: + return state.set('type', action.searchType); 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)); + const results = action.searchType === 'hashtags' ? ImmutableOrderedSet(fromJS(action.results.hashtags)) : action.results[action.searchType].map(item => item.id); + return state.updateIn(['results', action.searchType], list => list.union(results)); case SEARCH_RESULT_CLICK: return state.update('recent', set => set.add(fromJS(action.result))); case SEARCH_RESULT_FORGET: diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 60d54e42d2..c35bcbd741 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -5172,22 +5172,39 @@ a.status-card { } .search-results__section { - margin-bottom: 5px; + border-bottom: 1px solid lighten($ui-base-color, 8%); - h5 { + &:last-child { + border-bottom: 0; + } + + &__header { background: darken($ui-base-color, 4%); border-bottom: 1px solid lighten($ui-base-color, 8%); - cursor: default; - display: flex; padding: 15px; font-weight: 500; - font-size: 16px; - color: $dark-text-color; + font-size: 14px; + color: $darker-text-color; + display: flex; + justify-content: space-between; - .fa { - display: inline-block; + h3 .fa { margin-inline-end: 5px; } + + button { + color: $highlight-text-color; + padding: 0; + border: 0; + background: 0; + font: inherit; + + &:hover, + &:active, + &:focus { + text-decoration: underline; + } + } } .account:last-child, @@ -6815,14 +6832,14 @@ a.status-card { .notification__filter-bar, .account__section-headline { - background: darken($ui-base-color, 4%); + background: $ui-base-color; border-bottom: 1px solid lighten($ui-base-color, 8%); cursor: default; display: flex; flex-shrink: 0; button { - background: darken($ui-base-color, 4%); + background: transparent; border: 0; margin: 0; } @@ -6842,26 +6859,18 @@ a.status-card { white-space: nowrap; &.active { - color: $secondary-text-color; + color: $primary-text-color; - &::before, - &::after { + &::before { display: block; content: ''; position: absolute; - bottom: 0; - left: 50%; - width: 0; - height: 0; - transform: translateX(-50%); - border-style: solid; - border-width: 0 10px 10px; - border-color: transparent transparent lighten($ui-base-color, 8%); - } - - &::after { bottom: -1px; - border-color: transparent transparent $ui-base-color; + left: 0; + width: 100%; + height: 3px; + border-radius: 4px; + background: $highlight-text-color; } } } diff --git a/app/lib/tag_manager.rb b/app/lib/tag_manager.rb index ea096855f0..f1b61ff4fd 100644 --- a/app/lib/tag_manager.rb +++ b/app/lib/tag_manager.rb @@ -29,7 +29,7 @@ class TagManager domain = uri.host + (uri.port ? ":#{uri.port}" : '') TagManager.instance.web_domain?(domain) - rescue Addressable::URI::InvalidURIError + rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError false end end diff --git a/lib/mastodon/sidekiq_middleware.rb b/lib/mastodon/sidekiq_middleware.rb index 9832e1a27c..3a747afb63 100644 --- a/lib/mastodon/sidekiq_middleware.rb +++ b/lib/mastodon/sidekiq_middleware.rb @@ -16,7 +16,7 @@ class Mastodon::SidekiqMiddleware private def limit_backtrace_and_raise(exception) - exception.set_backtrace(exception.backtrace.first(BACKTRACE_LIMIT)) + exception.set_backtrace(exception.backtrace.first(BACKTRACE_LIMIT)) unless ENV['BACKTRACE'] raise exception end