diff --git a/Gemfile b/Gemfile
index 84f210f48..7a0fbdc82 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 c3eb9d4d7..b2d75e9d4 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 e38e14a10..abde8e92f 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 c0585e859..110943550 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 29ff897b9..16e91b449 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 bd784906d..65f3efc3a 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 42a3077de..fe07a870b 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 2a6202f84..f155979ef 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 000000000..9db45a0e4
--- /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 9a110f06e..df25e8648 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 f2b89f3bd..1ebecbd29 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 56b647701..683a2d79d 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 a6a7489e4..5a14e396c 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 0e632bc81..24e8a42a6 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 07ca42339..555375053 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 fdfa4104f..0bc2660b0 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 0b774b5e2..e39e2ba48 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 103702d8d..fcf72a0b1 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 bd784906d..65f3efc3a 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 73d42479b..aff38124b 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 726b5aa30..ac84014e4 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 000000000..e8e399f78
--- /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 41e5aa344..ae98aec0a 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 bdd1c7305..3d249e8d4 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 4de6c2ae6..d5e98461a 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 d40fefb39..59327f049 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 c1774512a..7b968204b 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 da3b6e19e..f1617a204 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 16047b26d..67aa5f6c5 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 486314c33..2bbf0f9a3 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 3641c00a4..07d1bda0f 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 02afc78ca..82d3684de 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 67d77793f..46cf68e1a 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 dfc3a45f8..3c9e73c12 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 d8e795f3b..d6e528654 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 f475f8153..dad8c0b28 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 7a46624ee..f2bfbeb22 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 850994b6d..dbdb688fc 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 7cb74c401..5cd4a744a 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 000000000..c3d2f1770
--- /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 ecfa376ab..66da06358 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 b18aedc4d..5e21802e7 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 219b5075d..80d6c8799 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 ad5bebb4e..99761b6c7 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 1283a23bf..3bf7f8ce9 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