diff --git a/Gemfile b/Gemfile index 84f210f481..7a0fbdc82d 100644 --- a/Gemfile +++ b/Gemfile @@ -3,8 +3,6 @@ source 'https://rubygems.org' ruby '>= 3.0.0' -gem 'pkg-config', '~> 1.5' - gem 'puma', '~> 6.3' gem 'rails', '~> 6.1.7' gem 'sprockets', '~> 3.7.2' diff --git a/Gemfile.lock b/Gemfile.lock index c3eb9d4d71..b2d75e9d4a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -478,7 +478,6 @@ GEM pg (1.5.3) pghero (3.3.3) activerecord (>= 6) - pkg-config (1.5.1) posix-spawn (0.3.15) premailer (1.21.0) addressable @@ -717,7 +716,7 @@ GEM unf_ext unf_ext (0.0.8.2) unicode-display_width (2.4.2) - uri (0.12.1) + uri (0.12.2) validate_email (0.1.6) activemodel (>= 3.0) mail (>= 2.2.5) @@ -833,7 +832,6 @@ DEPENDENCIES parslet pg (~> 1.5) pghero - pkg-config (~> 1.5) posix-spawn premailer-rails private_address_check (~> 0.5) diff --git a/app/chewy/accounts_index.rb b/app/chewy/accounts_index.rb index e38e14a106..abde8e92f1 100644 --- a/app/chewy/accounts_index.rb +++ b/app/chewy/accounts_index.rb @@ -2,8 +2,37 @@ class AccountsIndex < Chewy::Index settings index: { refresh_interval: '30s' }, analysis: { + filter: { + english_stop: { + type: 'stop', + stopwords: '_english_', + }, + + english_stemmer: { + type: 'stemmer', + language: 'english', + }, + + english_possessive_stemmer: { + type: 'stemmer', + language: 'possessive_english', + }, + }, + analyzer: { - content: { + natural: { + tokenizer: 'uax_url_email', + filter: %w( + english_possessive_stemmer + lowercase + asciifolding + cjk_width + english_stop + english_stemmer + ), + }, + + verbatim: { tokenizer: 'whitespace', filter: %w(lowercase asciifolding cjk_width), }, @@ -26,18 +55,13 @@ class AccountsIndex < Chewy::Index index_scope ::Account.searchable.includes(:account_stat) root date_detection: false do - field :id, type: 'long' - - field :display_name, type: 'text', analyzer: 'content' do - field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content' - end - - field :acct, type: 'text', analyzer: 'content', value: ->(account) { [account.username, account.domain].compact.join('@') } do - field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content' - end - - field :following_count, type: 'long', value: ->(account) { account.following_count } - field :followers_count, type: 'long', value: ->(account) { account.followers_count } - field :last_status_at, type: 'date', value: ->(account) { account.last_status_at || account.created_at } + field(:id, type: 'long') + field(:following_count, type: 'long') + field(:followers_count, type: 'long') + field(:properties, type: 'keyword', value: ->(account) { account.searchable_properties }) + field(:last_status_at, type: 'date', value: ->(account) { account.last_status_at || account.created_at }) + field(:display_name, type: 'text', analyzer: 'verbatim') { field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'verbatim' } + field(:username, type: 'text', analyzer: 'verbatim', value: ->(account) { [account.username, account.domain].compact.join('@') }) { field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'verbatim' } + field(:text, type: 'text', value: ->(account) { account.searchable_text }) { field :stemmed, type: 'text', analyzer: 'natural' } end end diff --git a/app/controllers/api/v1/directories_controller.rb b/app/controllers/api/v1/directories_controller.rb index c0585e8599..1109435507 100644 --- a/app/controllers/api/v1/directories_controller.rb +++ b/app/controllers/api/v1/directories_controller.rb @@ -21,11 +21,35 @@ class Api::V1::DirectoriesController < Api::BaseController def accounts_scope Account.discoverable.tap do |scope| - scope.merge!(Account.local) if truthy_param?(:local) - scope.merge!(Account.by_recent_status) if params[:order].blank? || params[:order] == 'active' - scope.merge!(Account.order(id: :desc)) if params[:order] == 'new' - scope.merge!(Account.not_excluded_by_account(current_account)) if current_account - scope.merge!(Account.not_domain_blocked_by_account(current_account)) if current_account && !truthy_param?(:local) + scope.merge!(account_order_scope) + scope.merge!(local_account_scope) if local_accounts? + scope.merge!(account_exclusion_scope) if current_account + scope.merge!(account_domain_block_scope) if current_account && !local_accounts? end end + + def local_accounts? + truthy_param?(:local) + end + + def account_order_scope + case params[:order] + when 'new' + Account.order(id: :desc) + when 'active', nil + Account.by_recent_status + end + end + + def local_account_scope + Account.local + end + + def account_exclusion_scope + Account.not_excluded_by_account(current_account) + end + + def account_domain_block_scope + Account.not_domain_blocked_by_account(current_account) + end end diff --git a/app/controllers/api/v1/emails/confirmations_controller.rb b/app/controllers/api/v1/emails/confirmations_controller.rb index 29ff897b91..16e91b4497 100644 --- a/app/controllers/api/v1/emails/confirmations_controller.rb +++ b/app/controllers/api/v1/emails/confirmations_controller.rb @@ -5,6 +5,7 @@ class Api::V1::Emails::ConfirmationsController < Api::BaseController before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :check before_action :require_user_owned_by_application!, except: :check before_action :require_user_not_confirmed!, except: :check + before_action :require_authenticated_user!, only: :check def create current_user.update!(email: params[:email]) if params.key?(:email) diff --git a/app/javascript/flavours/glitch/actions/server.js b/app/javascript/flavours/glitch/actions/server.js index bd784906d4..65f3efc3a7 100644 --- a/app/javascript/flavours/glitch/actions/server.js +++ b/app/javascript/flavours/glitch/actions/server.js @@ -19,6 +19,10 @@ export const SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS = 'SERVER_DOMAIN_BLOCKS_FETCH_SU export const SERVER_DOMAIN_BLOCKS_FETCH_FAIL = 'SERVER_DOMAIN_BLOCKS_FETCH_FAIL'; export const fetchServer = () => (dispatch, getState) => { + if (getState().getIn(['server', 'server', 'isLoading'])) { + return; + } + dispatch(fetchServerRequest()); api(getState) @@ -66,6 +70,10 @@ const fetchServerTranslationLanguagesFail = error => ({ }); export const fetchExtendedDescription = () => (dispatch, getState) => { + if (getState().getIn(['server', 'extendedDescription', 'isLoading'])) { + return; + } + dispatch(fetchExtendedDescriptionRequest()); api(getState) @@ -89,6 +97,10 @@ const fetchExtendedDescriptionFail = error => ({ }); export const fetchDomainBlocks = () => (dispatch, getState) => { + if (getState().getIn(['server', 'domainBlocks', 'isLoading'])) { + return; + } + dispatch(fetchDomainBlocksRequest()); api(getState) diff --git a/app/javascript/flavours/glitch/features/about/index.jsx b/app/javascript/flavours/glitch/features/about/index.jsx index 42a3077de6..fe07a870b2 100644 --- a/app/javascript/flavours/glitch/features/about/index.jsx +++ b/app/javascript/flavours/glitch/features/about/index.jsx @@ -161,7 +161,7 @@ class About extends PureComponent {
- {!isLoading && (server.get('rules').isEmpty() ? ( + {!isLoading && (server.get('rules', []).isEmpty() ? (

) : (
    diff --git a/app/javascript/flavours/glitch/features/compose/components/action_bar.jsx b/app/javascript/flavours/glitch/features/compose/components/action_bar.jsx index 2a6202f846..f155979ef9 100644 --- a/app/javascript/flavours/glitch/features/compose/components/action_bar.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/action_bar.jsx @@ -62,7 +62,7 @@ class ActionBar extends PureComponent { return (
    - +
    ); diff --git a/app/javascript/flavours/glitch/features/firehose/index.jsx b/app/javascript/flavours/glitch/features/firehose/index.jsx new file mode 100644 index 0000000000..9db45a0e47 --- /dev/null +++ b/app/javascript/flavours/glitch/features/firehose/index.jsx @@ -0,0 +1,218 @@ +import PropTypes from 'prop-types'; +import { useRef, useCallback, useEffect } from 'react'; + +import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; +import { NavLink } from 'react-router-dom'; + +import { addColumn } from 'flavours/glitch/actions/columns'; +import { changeSetting } from 'flavours/glitch/actions/settings'; +import { connectPublicStream, connectCommunityStream } from 'flavours/glitch/actions/streaming'; +import { expandPublicTimeline, expandCommunityTimeline } from 'flavours/glitch/actions/timelines'; +import DismissableBanner from 'flavours/glitch/components/dismissable_banner'; +import initialState, { domain } from 'flavours/glitch/initial_state'; +import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; + +import Column from '../../components/column'; +import ColumnHeader from '../../components/column_header'; +import SettingToggle from '../notifications/components/setting_toggle'; +import StatusListContainer from '../ui/containers/status_list_container'; + +const messages = defineMessages({ + title: { id: 'column.firehose', defaultMessage: 'Live feeds' }, +}); + +// TODO: use a proper React context later on +const useIdentity = () => ({ + signedIn: !!initialState.meta.me, + accountId: initialState.meta.me, + disabledAccountId: initialState.meta.disabled_account_id, + accessToken: initialState.meta.access_token, + permissions: initialState.role ? initialState.role.permissions : 0, +}); + +const ColumnSettings = () => { + const dispatch = useAppDispatch(); + const settings = useAppSelector((state) => state.getIn(['settings', 'firehose'])); + const onChange = useCallback( + (key, checked) => dispatch(changeSetting(['firehose', ...key], checked)), + [dispatch], + ); + + return ( +
    +
    + } + /> + } + /> +
    +
    + ); +}; + +const Firehose = ({ feedType, multiColumn }) => { + const dispatch = useAppDispatch(); + const intl = useIntl(); + const { signedIn } = useIdentity(); + const columnRef = useRef(null); + + const onlyMedia = useAppSelector((state) => state.getIn(['settings', 'firehose', 'onlyMedia'], false)); + const hasUnread = useAppSelector((state) => state.getIn(['timelines', `${feedType}${onlyMedia ? ':media' : ''}`, 'unread'], 0) > 0); + + const allowLocalOnly = useAppSelector((state) => state.getIn(['settings', 'firehose', 'allowLocalOnly'])); + + const handlePin = useCallback( + () => { + switch(feedType) { + case 'community': + dispatch(addColumn('COMMUNITY', { other: { onlyMedia } })); + break; + case 'public': + dispatch(addColumn('PUBLIC', { other: { onlyMedia, allowLocalOnly } })); + break; + case 'public:remote': + dispatch(addColumn('REMOTE', { other: { onlyMedia, onlyRemote: true } })); + break; + } + }, + [dispatch, onlyMedia, feedType, allowLocalOnly], + ); + + const handleLoadMore = useCallback( + (maxId) => { + switch(feedType) { + case 'community': + dispatch(expandCommunityTimeline({ onlyMedia })); + break; + case 'public': + dispatch(expandPublicTimeline({ maxId, onlyMedia, allowLocalOnly })); + break; + case 'public:remote': + dispatch(expandPublicTimeline({ maxId, onlyMedia, onlyRemote: true })); + break; + } + }, + [dispatch, onlyMedia, feedType], + ); + + const handleHeaderClick = useCallback(() => columnRef.current?.scrollTop(), []); + + useEffect(() => { + let disconnect; + + switch(feedType) { + case 'community': + dispatch(expandCommunityTimeline({ onlyMedia })); + if (signedIn) { + disconnect = dispatch(connectCommunityStream({ onlyMedia })); + } + break; + case 'public': + dispatch(expandPublicTimeline({ onlyMedia })); + if (signedIn) { + disconnect = dispatch(connectPublicStream({ onlyMedia, allowLocalOnly })); + } + break; + case 'public:remote': + dispatch(expandPublicTimeline({ onlyMedia, onlyRemote: true })); + if (signedIn) { + disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote: true })); + } + break; + } + + return () => disconnect?.(); + }, [dispatch, signedIn, feedType, onlyMedia]); + + const prependBanner = feedType === 'community' ? ( + + + + ) : ( + + + + ); + + const emptyMessage = feedType === 'community' ? ( + + ) : ( + + ); + + return ( + + + + + +
    +
    + + + + + + + + + + + +
    + + +
    + + + {intl.formatMessage(messages.title)} + + +
    + ); +} + +Firehose.propTypes = { + multiColumn: PropTypes.bool, + feedType: PropTypes.string, +}; + +export default Firehose; diff --git a/app/javascript/flavours/glitch/features/home_timeline/index.jsx b/app/javascript/flavours/glitch/features/home_timeline/index.jsx index 9a110f06e7..df25e86489 100644 --- a/app/javascript/flavours/glitch/features/home_timeline/index.jsx +++ b/app/javascript/flavours/glitch/features/home_timeline/index.jsx @@ -37,7 +37,7 @@ const getHomeFeedSpeed = createSelector([ state => state.get('statuses'), ], (statusIds, pendingStatusIds, statusMap) => { const recentStatusIds = pendingStatusIds.size > 0 ? pendingStatusIds : statusIds; - const statuses = recentStatusIds.map(id => statusMap.get(id)).filter(status => status?.get('account') !== me).take(20); + const statuses = recentStatusIds.filter(id => id !== null).map(id => statusMap.get(id)).filter(status => status?.get('account') !== me).take(20); const oldest = new Date(statuses.getIn([statuses.size - 1, 'created_at'], 0)); const newest = new Date(statuses.getIn([0, 'created_at'], 0)); const averageGap = (newest - oldest) / (1000 * (statuses.size + 1)); // Average gap between posts on first page in seconds diff --git a/app/javascript/flavours/glitch/features/ui/components/header.jsx b/app/javascript/flavours/glitch/features/ui/components/header.jsx index f2b89f3bdc..1ebecbd29d 100644 --- a/app/javascript/flavours/glitch/features/ui/components/header.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/header.jsx @@ -92,7 +92,6 @@ class Header extends PureComponent { content = ( <> - {location.pathname !== '/search' && } {signupButton} diff --git a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx index 56b6477016..683a2d79d9 100644 --- a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx @@ -18,8 +18,7 @@ const messages = defineMessages({ home: { id: 'tabs_bar.home', defaultMessage: 'Home' }, notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' }, explore: { id: 'explore.title', defaultMessage: 'Explore' }, - local: { id: 'tabs_bar.local_timeline', defaultMessage: 'Local' }, - federated: { id: 'tabs_bar.federated_timeline', defaultMessage: 'Federated' }, + firehose: { id: 'column.firehose', defaultMessage: 'Live feeds' }, direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' }, favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }, bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' }, @@ -43,6 +42,10 @@ class NavigationPanel extends Component { onOpenSettings: PropTypes.func, }; + isFirehoseActive = (match, location) => { + return match || location.pathname.startsWith('/public'); + }; + render() { const { intl, onOpenSettings } = this.props; const { signedIn, disabledAccountId } = this.context.identity; @@ -64,10 +67,7 @@ class NavigationPanel extends Component { )} {(signedIn || timelinePreview) && ( - <> - - - + )} {!signedIn && ( diff --git a/app/javascript/flavours/glitch/features/ui/index.jsx b/app/javascript/flavours/glitch/features/ui/index.jsx index a6a7489e45..5a14e396cc 100644 --- a/app/javascript/flavours/glitch/features/ui/index.jsx +++ b/app/javascript/flavours/glitch/features/ui/index.jsx @@ -37,8 +37,7 @@ import { Status, GettingStarted, KeyboardShortcuts, - PublicTimeline, - CommunityTimeline, + Firehose, AccountTimeline, AccountGallery, HomeTimeline, @@ -196,8 +195,11 @@ class SwitchingColumnsArea extends PureComponent { - - + + + + + diff --git a/app/javascript/flavours/glitch/features/ui/util/async-components.js b/app/javascript/flavours/glitch/features/ui/util/async-components.js index 0e632bc816..24e8a42a68 100644 --- a/app/javascript/flavours/glitch/features/ui/util/async-components.js +++ b/app/javascript/flavours/glitch/features/ui/util/async-components.js @@ -22,6 +22,10 @@ export function CommunityTimeline () { return import(/* webpackChunkName: "flavours/glitch/async/community_timeline" */'flavours/glitch/features/community_timeline'); } +export function Firehose () { + return import(/* webpackChunkName: "flavours/glitch/async/firehose" */'../../firehose'); +} + export function HashtagTimeline () { return import(/* webpackChunkName: "flavours/glitch/async/hashtag_timeline" */'flavours/glitch/features/hashtag_timeline'); } diff --git a/app/javascript/flavours/glitch/locales/en.json b/app/javascript/flavours/glitch/locales/en.json index 07ca42339d..5553750531 100644 --- a/app/javascript/flavours/glitch/locales/en.json +++ b/app/javascript/flavours/glitch/locales/en.json @@ -52,6 +52,7 @@ "empty_column.follow_recommendations": "Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.", "endorsed_accounts_editor.endorsed_accounts": "Featured accounts", "favourite_modal.combo": "You can press {combo} to skip this next time", + "firehose.column_settings.allow_local_only": "Show local-only posts in \"All\"", "follow_recommendations.done": "Done", "follow_recommendations.heading": "Follow people you'd like to see posts from! Here are some suggestions.", "follow_recommendations.lead": "Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!", diff --git a/app/javascript/flavours/glitch/reducers/index.ts b/app/javascript/flavours/glitch/reducers/index.ts index fdfa4104f6..0bc2660b06 100644 --- a/app/javascript/flavours/glitch/reducers/index.ts +++ b/app/javascript/flavours/glitch/reducers/index.ts @@ -1,3 +1,5 @@ +import { Record as ImmutableRecord } from 'immutable'; + import { loadingBarReducer } from 'react-redux-loading-bar'; import { combineReducers } from 'redux-immutable'; @@ -92,6 +94,22 @@ const reducers = { followed_tags, }; -const rootReducer = combineReducers(reducers); +// We want the root state to be an ImmutableRecord, which is an object with a defined list of keys, +// so it is properly typed and keys can be accessed using `state.` syntax. +// This will allow an easy conversion to a plain object once we no longer call `get` or `getIn` on the root state + +// By default with `combineReducers` it is a Collection, so we provide our own implementation to get a Record +const initialRootState = Object.fromEntries( + Object.entries(reducers).map(([name, reducer]) => [ + name, + reducer(undefined, { + // empty action + }), + ]) +); + +const RootStateRecord = ImmutableRecord(initialRootState, 'RootState'); + +const rootReducer = combineReducers(reducers, RootStateRecord); export { rootReducer }; diff --git a/app/javascript/flavours/glitch/reducers/server.js b/app/javascript/flavours/glitch/reducers/server.js index 0b774b5e20..e39e2ba48b 100644 --- a/app/javascript/flavours/glitch/reducers/server.js +++ b/app/javascript/flavours/glitch/reducers/server.js @@ -17,15 +17,15 @@ import { const initialState = ImmutableMap({ server: ImmutableMap({ - isLoading: true, + isLoading: false, }), extendedDescription: ImmutableMap({ - isLoading: true, + isLoading: false, }), domainBlocks: ImmutableMap({ - isLoading: true, + isLoading: false, isAvailable: true, items: ImmutableList(), }), diff --git a/app/javascript/flavours/glitch/reducers/settings.js b/app/javascript/flavours/glitch/reducers/settings.js index 103702d8d7..fcf72a0b15 100644 --- a/app/javascript/flavours/glitch/reducers/settings.js +++ b/app/javascript/flavours/glitch/reducers/settings.js @@ -84,6 +84,11 @@ const initialState = ImmutableMap({ }), }), + firehose: ImmutableMap({ + onlyMedia: false, + allowLocalOnly: true, + }), + community: ImmutableMap({ regex: ImmutableMap({ body: '', diff --git a/app/javascript/mastodon/actions/server.js b/app/javascript/mastodon/actions/server.js index bd784906d4..65f3efc3a7 100644 --- a/app/javascript/mastodon/actions/server.js +++ b/app/javascript/mastodon/actions/server.js @@ -19,6 +19,10 @@ export const SERVER_DOMAIN_BLOCKS_FETCH_SUCCESS = 'SERVER_DOMAIN_BLOCKS_FETCH_SU export const SERVER_DOMAIN_BLOCKS_FETCH_FAIL = 'SERVER_DOMAIN_BLOCKS_FETCH_FAIL'; export const fetchServer = () => (dispatch, getState) => { + if (getState().getIn(['server', 'server', 'isLoading'])) { + return; + } + dispatch(fetchServerRequest()); api(getState) @@ -66,6 +70,10 @@ const fetchServerTranslationLanguagesFail = error => ({ }); export const fetchExtendedDescription = () => (dispatch, getState) => { + if (getState().getIn(['server', 'extendedDescription', 'isLoading'])) { + return; + } + dispatch(fetchExtendedDescriptionRequest()); api(getState) @@ -89,6 +97,10 @@ const fetchExtendedDescriptionFail = error => ({ }); export const fetchDomainBlocks = () => (dispatch, getState) => { + if (getState().getIn(['server', 'domainBlocks', 'isLoading'])) { + return; + } + dispatch(fetchDomainBlocksRequest()); api(getState) diff --git a/app/javascript/mastodon/features/about/index.jsx b/app/javascript/mastodon/features/about/index.jsx index 73d42479b8..aff38124b6 100644 --- a/app/javascript/mastodon/features/about/index.jsx +++ b/app/javascript/mastodon/features/about/index.jsx @@ -161,7 +161,7 @@ class About extends PureComponent {
- {!isLoading && (server.get('rules').isEmpty() ? ( + {!isLoading && (server.get('rules', []).isEmpty() ? (

) : (
    diff --git a/app/javascript/mastodon/features/compose/components/action_bar.jsx b/app/javascript/mastodon/features/compose/components/action_bar.jsx index 726b5aa30d..ac84014e48 100644 --- a/app/javascript/mastodon/features/compose/components/action_bar.jsx +++ b/app/javascript/mastodon/features/compose/components/action_bar.jsx @@ -60,7 +60,7 @@ class ActionBar extends PureComponent { return (
    - +
    ); diff --git a/app/javascript/mastodon/features/firehose/index.jsx b/app/javascript/mastodon/features/firehose/index.jsx new file mode 100644 index 0000000000..e8e399f787 --- /dev/null +++ b/app/javascript/mastodon/features/firehose/index.jsx @@ -0,0 +1,210 @@ +import PropTypes from 'prop-types'; +import { useRef, useCallback, useEffect } from 'react'; + +import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; +import { NavLink } from 'react-router-dom'; + +import { addColumn } from 'mastodon/actions/columns'; +import { changeSetting } from 'mastodon/actions/settings'; +import { connectPublicStream, connectCommunityStream } from 'mastodon/actions/streaming'; +import { expandPublicTimeline, expandCommunityTimeline } from 'mastodon/actions/timelines'; +import DismissableBanner from 'mastodon/components/dismissable_banner'; +import initialState, { domain } from 'mastodon/initial_state'; +import { useAppDispatch, useAppSelector } from 'mastodon/store'; + +import Column from '../../components/column'; +import ColumnHeader from '../../components/column_header'; +import SettingToggle from '../notifications/components/setting_toggle'; +import StatusListContainer from '../ui/containers/status_list_container'; + +const messages = defineMessages({ + title: { id: 'column.firehose', defaultMessage: 'Live feeds' }, +}); + +// TODO: use a proper React context later on +const useIdentity = () => ({ + signedIn: !!initialState.meta.me, + accountId: initialState.meta.me, + disabledAccountId: initialState.meta.disabled_account_id, + accessToken: initialState.meta.access_token, + permissions: initialState.role ? initialState.role.permissions : 0, +}); + +const ColumnSettings = () => { + const dispatch = useAppDispatch(); + const settings = useAppSelector((state) => state.getIn(['settings', 'firehose'])); + const onChange = useCallback( + (key, checked) => dispatch(changeSetting(['firehose', ...key], checked)), + [dispatch], + ); + + return ( +
    +
    + } + /> +
    +
    + ); +}; + +const Firehose = ({ feedType, multiColumn }) => { + const dispatch = useAppDispatch(); + const intl = useIntl(); + const { signedIn } = useIdentity(); + const columnRef = useRef(null); + + const onlyMedia = useAppSelector((state) => state.getIn(['settings', 'firehose', 'onlyMedia'], false)); + const hasUnread = useAppSelector((state) => state.getIn(['timelines', `${feedType}${onlyMedia ? ':media' : ''}`, 'unread'], 0) > 0); + + const handlePin = useCallback( + () => { + switch(feedType) { + case 'community': + dispatch(addColumn('COMMUNITY', { other: { onlyMedia } })); + break; + case 'public': + dispatch(addColumn('PUBLIC', { other: { onlyMedia } })); + break; + case 'public:remote': + dispatch(addColumn('REMOTE', { other: { onlyMedia, onlyRemote: true } })); + break; + } + }, + [dispatch, onlyMedia, feedType], + ); + + const handleLoadMore = useCallback( + (maxId) => { + switch(feedType) { + case 'community': + dispatch(expandCommunityTimeline({ onlyMedia })); + break; + case 'public': + dispatch(expandPublicTimeline({ maxId, onlyMedia })); + break; + case 'public:remote': + dispatch(expandPublicTimeline({ maxId, onlyMedia, onlyRemote: true })); + break; + } + }, + [dispatch, onlyMedia, feedType], + ); + + const handleHeaderClick = useCallback(() => columnRef.current?.scrollTop(), []); + + useEffect(() => { + let disconnect; + + switch(feedType) { + case 'community': + dispatch(expandCommunityTimeline({ onlyMedia })); + if (signedIn) { + disconnect = dispatch(connectCommunityStream({ onlyMedia })); + } + break; + case 'public': + dispatch(expandPublicTimeline({ onlyMedia })); + if (signedIn) { + disconnect = dispatch(connectPublicStream({ onlyMedia })); + } + break; + case 'public:remote': + dispatch(expandPublicTimeline({ onlyMedia, onlyRemote: true })); + if (signedIn) { + disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote: true })); + } + break; + } + + return () => disconnect?.(); + }, [dispatch, signedIn, feedType, onlyMedia]); + + const prependBanner = feedType === 'community' ? ( + + + + ) : ( + + + + ); + + const emptyMessage = feedType === 'community' ? ( + + ) : ( + + ); + + return ( + + + + + +
    +
    + + + + + + + + + + + +
    + + +
    + + + {intl.formatMessage(messages.title)} + + +
    + ); +} + +Firehose.propTypes = { + multiColumn: PropTypes.bool, + feedType: PropTypes.string, +}; + +export default Firehose; diff --git a/app/javascript/mastodon/features/home_timeline/index.jsx b/app/javascript/mastodon/features/home_timeline/index.jsx index 41e5aa3447..ae98aec0a6 100644 --- a/app/javascript/mastodon/features/home_timeline/index.jsx +++ b/app/javascript/mastodon/features/home_timeline/index.jsx @@ -37,7 +37,7 @@ const getHomeFeedSpeed = createSelector([ state => state.get('statuses'), ], (statusIds, pendingStatusIds, statusMap) => { const recentStatusIds = pendingStatusIds.size > 0 ? pendingStatusIds : statusIds; - const statuses = recentStatusIds.map(id => statusMap.get(id)).filter(status => status?.get('account') !== me).take(20); + const statuses = recentStatusIds.filter(id => id !== null).map(id => statusMap.get(id)).filter(status => status?.get('account') !== me).take(20); const oldest = new Date(statuses.getIn([statuses.size - 1, 'created_at'], 0)); const newest = new Date(statuses.getIn([0, 'created_at'], 0)); const averageGap = (newest - oldest) / (1000 * (statuses.size + 1)); // Average gap between posts on first page in seconds diff --git a/app/javascript/mastodon/features/ui/components/header.jsx b/app/javascript/mastodon/features/ui/components/header.jsx index bdd1c73052..3d249e8d4f 100644 --- a/app/javascript/mastodon/features/ui/components/header.jsx +++ b/app/javascript/mastodon/features/ui/components/header.jsx @@ -91,7 +91,6 @@ class Header extends PureComponent { content = ( <> - {location.pathname !== '/search' && } {signupButton} diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.jsx b/app/javascript/mastodon/features/ui/components/navigation_panel.jsx index 4de6c2ae63..d5e98461aa 100644 --- a/app/javascript/mastodon/features/ui/components/navigation_panel.jsx +++ b/app/javascript/mastodon/features/ui/components/navigation_panel.jsx @@ -20,8 +20,7 @@ const messages = defineMessages({ home: { id: 'tabs_bar.home', defaultMessage: 'Home' }, notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' }, explore: { id: 'explore.title', defaultMessage: 'Explore' }, - local: { id: 'tabs_bar.local_timeline', defaultMessage: 'Local' }, - federated: { id: 'tabs_bar.federated_timeline', defaultMessage: 'Federated' }, + firehose: { id: 'column.firehose', defaultMessage: 'Live feeds' }, direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' }, favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }, bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' }, @@ -43,6 +42,10 @@ class NavigationPanel extends Component { intl: PropTypes.object.isRequired, }; + isFirehoseActive = (match, location) => { + return match || location.pathname.startsWith('/public'); + }; + render () { const { intl } = this.props; const { signedIn, disabledAccountId } = this.context.identity; @@ -69,10 +72,7 @@ class NavigationPanel extends Component { )} {(signedIn || timelinePreview) && ( - <> - - - + )} {!signedIn && ( diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index d40fefb39f..59327f0496 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -36,8 +36,7 @@ import { Status, GettingStarted, KeyboardShortcuts, - PublicTimeline, - CommunityTimeline, + Firehose, AccountTimeline, AccountGallery, HomeTimeline, @@ -188,8 +187,11 @@ class SwitchingColumnsArea extends PureComponent { - - + + + + + diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index c1774512a0..7b968204be 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -22,6 +22,10 @@ export function CommunityTimeline () { return import(/* webpackChunkName: "features/community_timeline" */'../../community_timeline'); } +export function Firehose () { + return import(/* webpackChunkName: "features/firehose" */'../../firehose'); +} + export function HashtagTimeline () { return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline'); } diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index da3b6e19eb..f1617a2040 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -114,6 +114,7 @@ "column.directory": "Browse profiles", "column.domain_blocks": "Blocked domains", "column.favourites": "Favourites", + "column.firehose": "Live feeds", "column.follow_requests": "Follow requests", "column.home": "Home", "column.lists": "Lists", @@ -267,6 +268,9 @@ "filter_modal.select_filter.subtitle": "Use an existing category or create a new one", "filter_modal.select_filter.title": "Filter this post", "filter_modal.title.status": "Filter a post", + "firehose.all": "All", + "firehose.local": "Local", + "firehose.remote": "Remote", "follow_request.authorize": "Authorize", "follow_request.reject": "Reject", "follow_requests.unlocked_explanation": "Even though your account is not locked, the {domain} staff thought you might want to review follow requests from these accounts manually.", @@ -649,9 +653,7 @@ "subscribed_languages.target": "Change subscribed languages for {target}", "suggestions.dismiss": "Dismiss suggestion", "suggestions.header": "You might be interested in…", - "tabs_bar.federated_timeline": "Federated", "tabs_bar.home": "Home", - "tabs_bar.local_timeline": "Local", "tabs_bar.notifications": "Notifications", "time_remaining.days": "{number, plural, one {# day} other {# days}} left", "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left", diff --git a/app/javascript/mastodon/reducers/index.ts b/app/javascript/mastodon/reducers/index.ts index 16047b26d8..67aa5f6c5e 100644 --- a/app/javascript/mastodon/reducers/index.ts +++ b/app/javascript/mastodon/reducers/index.ts @@ -1,3 +1,5 @@ +import { Record as ImmutableRecord } from 'immutable'; + import { loadingBarReducer } from 'react-redux-loading-bar'; import { combineReducers } from 'redux-immutable'; @@ -88,6 +90,22 @@ const reducers = { followed_tags, }; -const rootReducer = combineReducers(reducers); +// We want the root state to be an ImmutableRecord, which is an object with a defined list of keys, +// so it is properly typed and keys can be accessed using `state.` syntax. +// This will allow an easy conversion to a plain object once we no longer call `get` or `getIn` on the root state + +// By default with `combineReducers` it is a Collection, so we provide our own implementation to get a Record +const initialRootState = Object.fromEntries( + Object.entries(reducers).map(([name, reducer]) => [ + name, + reducer(undefined, { + // empty action + }), + ]) +); + +const RootStateRecord = ImmutableRecord(initialRootState, 'RootState'); + +const rootReducer = combineReducers(reducers, RootStateRecord); export { rootReducer }; diff --git a/app/javascript/mastodon/reducers/server.js b/app/javascript/mastodon/reducers/server.js index 486314c338..2bbf0f9a30 100644 --- a/app/javascript/mastodon/reducers/server.js +++ b/app/javascript/mastodon/reducers/server.js @@ -17,15 +17,15 @@ import { const initialState = ImmutableMap({ server: ImmutableMap({ - isLoading: true, + isLoading: false, }), extendedDescription: ImmutableMap({ - isLoading: true, + isLoading: false, }), domainBlocks: ImmutableMap({ - isLoading: true, + isLoading: false, isAvailable: true, items: ImmutableList(), }), diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js index 3641c00a45..07d1bda0f4 100644 --- a/app/javascript/mastodon/reducers/settings.js +++ b/app/javascript/mastodon/reducers/settings.js @@ -79,6 +79,10 @@ const initialState = ImmutableMap({ }), }), + firehose: ImmutableMap({ + onlyMedia: false, + }), + community: ImmutableMap({ regex: ImmutableMap({ body: '', diff --git a/app/models/account.rb b/app/models/account.rb index 02afc78ca4..82d3684dec 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -116,7 +116,7 @@ class Account < ApplicationRecord scope :matches_username, ->(value) { where('lower((username)::text) LIKE lower(?)', "#{value}%") } scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) } scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) } - scope :without_unapproved, -> { left_outer_joins(:user).remote.or(left_outer_joins(:user).merge(User.approved.confirmed)) } + scope :without_unapproved, -> { left_outer_joins(:user).merge(User.approved.confirmed).or(remote) } scope :searchable, -> { without_unapproved.without_suspended.where(moved_to_account_id: nil) } scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).left_outer_joins(:account_stat) } scope :followable_by, ->(account) { joins(arel_table.join(Follow.arel_table, Arel::Nodes::OuterJoin).on(arel_table[:id].eq(Follow.arel_table[:target_account_id]).and(Follow.arel_table[:account_id].eq(account.id))).join_sources).where(Follow.arel_table[:id].eq(nil)).joins(arel_table.join(FollowRequest.arel_table, Arel::Nodes::OuterJoin).on(arel_table[:id].eq(FollowRequest.arel_table[:target_account_id]).and(FollowRequest.arel_table[:account_id].eq(account.id))).join_sources).where(FollowRequest.arel_table[:id].eq(nil)) } diff --git a/app/models/concerns/account_search.rb b/app/models/concerns/account_search.rb index 67d77793fe..46cf68e1a3 100644 --- a/app/models/concerns/account_search.rb +++ b/app/models/concerns/account_search.rb @@ -106,6 +106,17 @@ module AccountSearch LIMIT :limit OFFSET :offset SQL + def searchable_text + PlainTextFormatter.new(note, local?).to_s if discoverable? + end + + def searchable_properties + [].tap do |properties| + properties << 'bot' if bot? + properties << 'verified' if fields.any?(&:verified?) + end + end + class_methods do def search_for(terms, limit: 10, offset: 0) tsquery = generate_query_for_search(terms) diff --git a/app/services/account_search_service.rb b/app/services/account_search_service.rb index dfc3a45f8f..3c9e73c124 100644 --- a/app/services/account_search_service.rb +++ b/app/services/account_search_service.rb @@ -9,12 +9,11 @@ class AccountSearchService < BaseService MIN_QUERY_LENGTH = 5 def call(query, account = nil, options = {}) - @acct_hint = query&.start_with?('@') - @query = query&.strip&.gsub(/\A@/, '') - @limit = options[:limit].to_i - @offset = options[:offset].to_i - @options = options - @account = account + @query = query&.strip&.gsub(/\A@/, '') + @limit = options[:limit].to_i + @offset = options[:offset].to_i + @options = options + @account = account search_service_results.compact.uniq end @@ -72,8 +71,8 @@ class AccountSearchService < BaseService end def from_elasticsearch - must_clauses = [{ multi_match: { query: terms_for_query, fields: likely_acct? ? %w(acct.edge_ngram acct) : %w(acct.edge_ngram acct display_name.edge_ngram display_name), type: 'most_fields', operator: 'and' } }] - should_clauses = [] + must_clauses = must_clause + should_clauses = should_clause if account return [] if options[:following] && following_ids.empty? @@ -88,7 +87,7 @@ class AccountSearchService < BaseService query = { bool: { must: must_clauses, should: should_clauses } } functions = [reputation_score_function, followers_score_function, time_distance_function] - records = AccountsIndex.query(function_score: { query: query, functions: functions, boost_mode: 'multiply', score_mode: 'avg' }) + records = AccountsIndex.query(function_score: { query: query, functions: functions }) .limit(limit_for_non_exact_results) .offset(offset) .objects @@ -133,6 +132,36 @@ class AccountSearchService < BaseService } end + def must_clause + fields = %w(username username.* display_name display_name.*) + fields << 'text' << 'text.*' if options[:use_searchable_text] + + [ + { + multi_match: { + query: terms_for_query, + fields: fields, + type: 'best_fields', + operator: 'or', + }, + }, + ] + end + + def should_clause + [ + { + multi_match: { + query: terms_for_query, + fields: %w(username username.* display_name display_name.*), + type: 'best_fields', + operator: 'and', + boost: 10, + }, + }, + ] + end + def following_ids @following_ids ||= account.active_relationships.pluck(:target_account_id) + [account.id] end @@ -182,8 +211,4 @@ class AccountSearchService < BaseService def username_complete? query.include?('@') && "@#{query}".match?(MENTION_ONLY_RE) end - - def likely_acct? - @acct_hint || username_complete? - end end diff --git a/app/services/resolve_url_service.rb b/app/services/resolve_url_service.rb index d8e795f3b0..d6e528654f 100644 --- a/app/services/resolve_url_service.rb +++ b/app/services/resolve_url_service.rb @@ -89,13 +89,28 @@ class ResolveURLService < BaseService def process_local_url recognized_params = Rails.application.routes.recognize_path(@url) - return unless recognized_params[:action] == 'show' + case recognized_params[:controller] + when 'statuses' + return unless recognized_params[:action] == 'show' - if recognized_params[:controller] == 'statuses' status = Status.find_by(id: recognized_params[:id]) check_local_status(status) - elsif recognized_params[:controller] == 'accounts' + when 'accounts' + return unless recognized_params[:action] == 'show' + Account.find_local(recognized_params[:username]) + when 'home' + return unless recognized_params[:action] == 'index' && recognized_params[:username_with_domain].present? + + if recognized_params[:any]&.match?(/\A[0-9]+\Z/) + status = Status.find_by(id: recognized_params[:any]) + check_local_status(status) + elsif recognized_params[:any].blank? + username, domain = recognized_params[:username_with_domain].gsub(/\A@/, '').split('@') + return unless username.present? && domain.present? + + Account.find_remote(username, domain) + end end end diff --git a/app/services/search_service.rb b/app/services/search_service.rb index f475f81536..dad8c0b28f 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -30,7 +30,8 @@ class SearchService < BaseService @account, limit: @limit, resolve: @resolve, - offset: @offset + offset: @offset, + use_searchable_text: true ) end diff --git a/config/routes.rb b/config/routes.rb index 7a46624ee8..f2bfbeb22b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -104,8 +104,6 @@ Rails.application.routes.draw do resources :followers, only: [:index], controller: :follower_accounts resources :following, only: [:index], controller: :following_accounts - resource :follow, only: [:create], controller: :account_follow - resource :unfollow, only: [:create], controller: :account_unfollow resource :outbox, only: [:show], module: :activitypub resource :inbox, only: [:create], module: :activitypub @@ -165,7 +163,7 @@ Rails.application.routes.draw do get '/backups/:id/download', to: 'backups#download', as: :download_backup, format: false resource :authorize_interaction, only: [:show, :create] - resource :share, only: [:show, :create] + resource :share, only: [:show] draw(:admin) diff --git a/config/routes/admin.rb b/config/routes/admin.rb index 850994b6d0..dbdb688fce 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -3,7 +3,7 @@ namespace :admin do get '/dashboard', to: 'dashboard#index' - resources :domain_allows, only: [:new, :create, :show, :destroy] + resources :domain_allows, only: [:new, :create, :destroy] resources :domain_blocks, only: [:new, :create, :destroy, :update, :edit] do collection do post :batch @@ -31,7 +31,7 @@ namespace :admin do end resources :action_logs, only: [:index] - resources :warning_presets, except: [:new] + resources :warning_presets, except: [:new, :show] resources :announcements, except: [:show] do member do @@ -76,7 +76,7 @@ namespace :admin do end end - resources :rules + resources :rules, only: [:index, :create, :edit, :update, :destroy] resources :webhooks do member do diff --git a/crowdin.yml b/crowdin.yml index 7cb74c4010..5cd4a744aa 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -1,6 +1,5 @@ +skip_untranslated_strings: 1 commit_message: '[ci skip]' -skip_untranslated_strings: true - files: - source: /app/javascript/mastodon/locales/en.json translation: /app/javascript/mastodon/locales/%two_letters_code%.json diff --git a/db/migrate/20230630145300_add_index_backups_on_user_id.rb b/db/migrate/20230630145300_add_index_backups_on_user_id.rb new file mode 100644 index 0000000000..c3d2f17707 --- /dev/null +++ b/db/migrate/20230630145300_add_index_backups_on_user_id.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddIndexBackupsOnUserId < ActiveRecord::Migration[6.1] + disable_ddl_transaction! + + def change + add_index :backups, :user_id, algorithm: :concurrently + end +end diff --git a/db/schema.rb b/db/schema.rb index ecfa376ab0..66da06358d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2023_06_05_085711) do +ActiveRecord::Schema.define(version: 2023_06_30_145300) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -273,6 +273,7 @@ ActiveRecord::Schema.define(version: 2023_06_05_085711) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.bigint "dump_file_size" + t.index ["user_id"], name: "index_backups_on_user_id" end create_table "blocks", force: :cascade do |t| diff --git a/spec/controllers/api/v1/directories_controller_spec.rb b/spec/controllers/api/v1/directories_controller_spec.rb index b18aedc4d1..5e21802e7a 100644 --- a/spec/controllers/api/v1/directories_controller_spec.rb +++ b/spec/controllers/api/v1/directories_controller_spec.rb @@ -5,19 +5,124 @@ require 'rails_helper' describe Api::V1::DirectoriesController do render_views - let(:user) { Fabricate(:user) } + let(:user) { Fabricate(:user, confirmed_at: nil) } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:follows') } - let(:account) { Fabricate(:account) } before do allow(controller).to receive(:doorkeeper_token) { token } end describe 'GET #show' do - it 'returns http success' do - get :show + context 'with no params' do + before do + _local_unconfirmed_account = Fabricate( + :account, + domain: nil, + user: Fabricate(:user, confirmed_at: nil, approved: true), + username: 'local_unconfirmed' + ) - expect(response).to have_http_status(200) + local_unapproved_account = Fabricate( + :account, + domain: nil, + user: Fabricate(:user, confirmed_at: 10.days.ago), + username: 'local_unapproved' + ) + local_unapproved_account.user.update(approved: false) + + _local_undiscoverable_account = Fabricate( + :account, + domain: nil, + user: Fabricate(:user, confirmed_at: 10.days.ago, approved: true), + discoverable: false, + username: 'local_undiscoverable' + ) + + excluded_from_timeline_account = Fabricate( + :account, + domain: 'host.example', + discoverable: true, + username: 'remote_excluded_from_timeline' + ) + Fabricate(:block, account: user.account, target_account: excluded_from_timeline_account) + + _domain_blocked_account = Fabricate( + :account, + domain: 'test.example', + discoverable: true, + username: 'remote_domain_blocked' + ) + Fabricate(:account_domain_block, account: user.account, domain: 'test.example') + end + + it 'returns only the local discoverable account' do + local_discoverable_account = Fabricate( + :account, + domain: nil, + user: Fabricate(:user, confirmed_at: 10.days.ago, approved: true), + discoverable: true, + username: 'local_discoverable' + ) + + eligible_remote_account = Fabricate( + :account, + domain: 'host.example', + discoverable: true, + username: 'eligible_remote' + ) + + get :show + + expect(response).to have_http_status(200) + expect(body_as_json.size).to eq(2) + expect(body_as_json.first[:id]).to include(eligible_remote_account.id.to_s) + expect(body_as_json.second[:id]).to include(local_discoverable_account.id.to_s) + end + end + + context 'when asking for local accounts only' do + it 'returns only the local accounts' do + user = Fabricate(:user, confirmed_at: 10.days.ago, approved: true) + local_account = Fabricate(:account, domain: nil, user: user) + remote_account = Fabricate(:account, domain: 'host.example') + + get :show, params: { local: '1' } + + expect(response).to have_http_status(200) + expect(body_as_json.size).to eq(1) + expect(body_as_json.first[:id]).to include(local_account.id.to_s) + expect(response.body).to_not include(remote_account.id.to_s) + end + end + + context 'when ordered by active' do + it 'returns accounts in order of most recent status activity' do + status_old = Fabricate(:status) + travel_to 10.seconds.from_now + status_new = Fabricate(:status) + + get :show, params: { order: 'active' } + + expect(response).to have_http_status(200) + expect(body_as_json.size).to eq(2) + expect(body_as_json.first[:id]).to include(status_new.account.id.to_s) + expect(body_as_json.second[:id]).to include(status_old.account.id.to_s) + end + end + + context 'when ordered by new' do + it 'returns accounts in order of creation' do + account_old = Fabricate(:account) + travel_to 10.seconds.from_now + account_new = Fabricate(:account) + + get :show, params: { order: 'new' } + + expect(response).to have_http_status(200) + expect(body_as_json.size).to eq(2) + expect(body_as_json.first[:id]).to include(account_new.id.to_s) + expect(body_as_json.second[:id]).to include(account_old.id.to_s) + end end end end diff --git a/spec/controllers/api/v1/emails/confirmations_controller_spec.rb b/spec/controllers/api/v1/emails/confirmations_controller_spec.rb index 219b5075df..80d6c8799d 100644 --- a/spec/controllers/api/v1/emails/confirmations_controller_spec.rb +++ b/spec/controllers/api/v1/emails/confirmations_controller_spec.rb @@ -130,5 +130,13 @@ RSpec.describe Api::V1::Emails::ConfirmationsController do end end end + + context 'without an oauth token and an authentication cookie' do + it 'returns http unauthorized' do + get :check + + expect(response).to have_http_status(401) + end + end end end diff --git a/spec/services/resolve_url_service_spec.rb b/spec/services/resolve_url_service_spec.rb index ad5bebb4ed..99761b6c73 100644 --- a/spec/services/resolve_url_service_spec.rb +++ b/spec/services/resolve_url_service_spec.rb @@ -145,5 +145,35 @@ describe ResolveURLService, type: :service do expect(subject.call(url, on_behalf_of: account)).to eq(status) end end + + context 'when searching for a local link of a remote private status' do + let(:account) { Fabricate(:account) } + let(:poster) { Fabricate(:account, username: 'foo', domain: 'example.com') } + let(:url) { 'https://example.com/@foo/42' } + let(:uri) { 'https://example.com/users/foo/statuses/42' } + let!(:status) { Fabricate(:status, url: url, uri: uri, account: poster, visibility: :private) } + let(:search_url) { "https://#{Rails.configuration.x.local_domain}/@foo@example.com/#{status.id}" } + + before do + stub_request(:get, url).to_return(status: 404) if url.present? + stub_request(:get, uri).to_return(status: 404) + end + + context 'when the account follows the poster' do + before do + account.follow!(poster) + end + + it 'returns the status' do + expect(subject.call(search_url, on_behalf_of: account)).to eq(status) + end + end + + context 'when the account does not follow the poster' do + it 'does not return the status' do + expect(subject.call(search_url, on_behalf_of: account)).to be_nil + end + end + end end end diff --git a/spec/services/search_service_spec.rb b/spec/services/search_service_spec.rb index 1283a23bf1..3bf7f8ce9f 100644 --- a/spec/services/search_service_spec.rb +++ b/spec/services/search_service_spec.rb @@ -68,7 +68,7 @@ describe SearchService, type: :service do allow(AccountSearchService).to receive(:new).and_return(service) results = subject.call(query, nil, 10) - expect(service).to have_received(:call).with(query, nil, limit: 10, offset: 0, resolve: false) + expect(service).to have_received(:call).with(query, nil, limit: 10, offset: 0, resolve: false, use_searchable_text: true) expect(results).to eq empty_results.merge(accounts: [account]) end end