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/controllers/api/v2/search_controller.rb b/app/controllers/api/v2/search_controller.rb
index b084eae425..cc74db58e5 100644
--- a/app/controllers/api/v2/search_controller.rb
+++ b/app/controllers/api/v2/search_controller.rb
@@ -34,11 +34,11 @@ class Api::V2::SearchController < Api::BaseController
params[:q],
current_account,
limit_param(RESULTS_LIMIT),
- search_params.merge(resolve: truthy_param?(:resolve), exclude_unreviewed: truthy_param?(:exclude_unreviewed))
+ search_params.merge(resolve: truthy_param?(:resolve), exclude_unreviewed: truthy_param?(:exclude_unreviewed), following: truthy_param?(:following))
)
end
def search_params
- params.permit(:type, :offset, :min_id, :max_id, :account_id)
+ params.permit(:type, :offset, :min_id, :max_id, :account_id, :following)
end
end
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/components/poll.jsx b/app/javascript/flavours/glitch/components/poll.jsx
index eca7b5c525..623d343806 100644
--- a/app/javascript/flavours/glitch/components/poll.jsx
+++ b/app/javascript/flavours/glitch/components/poll.jsx
@@ -131,6 +131,10 @@ class Poll extends ImmutablePureComponent {
this.props.refresh();
};
+ handleReveal = () => {
+ this.setState({ revealed: true });
+ }
+
renderOption (option, optionIndex, showResults) {
const { poll, lang, disabled, intl } = this.props;
const pollVotesCount = poll.get('voters_count') || poll.get('votes_count');
@@ -206,14 +210,14 @@ class Poll extends ImmutablePureComponent {
render () {
const { poll, intl } = this.props;
- const { expired } = this.state;
+ const { revealed, expired } = this.state;
if (!poll) {
return null;
}
const timeRemaining = expired ? intl.formatMessage(messages.closed) : ;
- const showResults = poll.get('voted') || expired;
+ const showResults = poll.get('voted') || revealed || expired;
const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item);
let votesCount = null;
@@ -232,9 +236,10 @@ class Poll extends ImmutablePureComponent {
{!showResults && }
- {showResults && !this.props.disabled && · }
+ {!showResults && <> · >}
+ {showResults && !this.props.disabled && <> · >}
{votesCount}
- {poll.get('expires_at') && · {timeRemaining}}
+ {poll.get('expires_at') && <> · {timeRemaining}>}
);
diff --git a/app/javascript/flavours/glitch/components/status_content.jsx b/app/javascript/flavours/glitch/components/status_content.jsx
index 9b1e2e0265..955bf661ca 100644
--- a/app/javascript/flavours/glitch/components/status_content.jsx
+++ b/app/javascript/flavours/glitch/components/status_content.jsx
@@ -163,7 +163,7 @@ class StatusContent extends PureComponent {
if (mention) {
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
- link.setAttribute('title', mention.get('acct'));
+ link.setAttribute('title', `@${mention.get('acct')}`);
if (rewriteMentions !== 'no') {
while (link.firstChild) link.removeChild(link.firstChild);
link.appendChild(document.createTextNode('@'));
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/account/components/header.jsx b/app/javascript/flavours/glitch/features/account/components/header.jsx
index ca2eb37eb1..0c440dc8a3 100644
--- a/app/javascript/flavours/glitch/features/account/components/header.jsx
+++ b/app/javascript/flavours/glitch/features/account/components/header.jsx
@@ -398,6 +398,7 @@ class Header extends ImmutablePureComponent {
{titleFromAccount(account)}
+
);
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..53a39eb63d
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/firehose/index.jsx
@@ -0,0 +1,231 @@
+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 SettingText from 'flavours/glitch/components/setting_text';
+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' },
+ filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' },
+});
+
+// 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 intl = useIntl();
+ 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 regex = useAppSelector((state) => state.getIn(['settings', 'firehose', 'regex', 'body']));
+
+ const handlePin = useCallback(
+ () => {
+ switch(feedType) {
+ case 'community':
+ dispatch(addColumn('COMMUNITY', { other: { onlyMedia }, regex: { body: regex } }));
+ break;
+ case 'public':
+ dispatch(addColumn('PUBLIC', { other: { onlyMedia, allowLocalOnly }, regex: { body: regex } }));
+ break;
+ case 'public:remote':
+ dispatch(addColumn('REMOTE', { other: { onlyMedia, onlyRemote: true }, regex: { body: regex } }));
+ break;
+ }
+ },
+ [dispatch, onlyMedia, feedType, allowLocalOnly, regex],
+ );
+
+ const handleLoadMore = useCallback(
+ (maxId) => {
+ switch(feedType) {
+ case 'community':
+ dispatch(expandCommunityTimeline({ maxId, onlyMedia }));
+ break;
+ case 'public':
+ dispatch(expandPublicTimeline({ maxId, onlyMedia, allowLocalOnly }));
+ break;
+ case 'public:remote':
+ dispatch(expandPublicTimeline({ maxId, onlyMedia, onlyRemote: true }));
+ break;
+ }
+ },
+ [dispatch, onlyMedia, allowLocalOnly, 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, allowLocalOnly]);
+
+ 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/public_timeline/index.jsx b/app/javascript/flavours/glitch/features/public_timeline/index.jsx
index 4e4b350f8b..c1e49e6ef3 100644
--- a/app/javascript/flavours/glitch/features/public_timeline/index.jsx
+++ b/app/javascript/flavours/glitch/features/public_timeline/index.jsx
@@ -13,6 +13,7 @@ import { expandPublicTimeline } from 'flavours/glitch/actions/timelines';
import Column from 'flavours/glitch/components/column';
import ColumnHeader from 'flavours/glitch/components/column_header';
import DismissableBanner from 'flavours/glitch/components/dismissable_banner';
+import { domain } from 'flavours/glitch/initial_state';
import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
import ColumnSettingsContainer from './containers/column_settings_container';
@@ -147,7 +148,7 @@ class PublicTimeline extends PureComponent {
}
+ prepend={}
timelineId={`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`}
onLoadMore={this.handleLoadMore}
trackScroll={!pinned}
diff --git a/app/javascript/flavours/glitch/features/status/index.jsx b/app/javascript/flavours/glitch/features/status/index.jsx
index f6b01605a3..c986e7d319 100644
--- a/app/javascript/flavours/glitch/features/status/index.jsx
+++ b/app/javascript/flavours/glitch/features/status/index.jsx
@@ -772,6 +772,7 @@ class Status extends ImmutablePureComponent {
{titleFromStatus(intl, status)}
+
);
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 747ca14c87..be7fbdcd3c 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 0427162ab7..748523176b 100644
--- a/app/javascript/flavours/glitch/reducers/settings.js
+++ b/app/javascript/flavours/glitch/reducers/settings.js
@@ -87,6 +87,15 @@ const initialState = ImmutableMap({
}),
}),
+ firehose: ImmutableMap({
+ onlyMedia: false,
+ allowLocalOnly: true,
+
+ regex: ImmutableMap({
+ body: '',
+ }),
+ }),
+
community: ImmutableMap({
regex: ImmutableMap({
body: '',
diff --git a/app/javascript/flavours/glitch/styles/admin.scss b/app/javascript/flavours/glitch/styles/admin.scss
index a57e014e9e..7adeaeee01 100644
--- a/app/javascript/flavours/glitch/styles/admin.scss
+++ b/app/javascript/flavours/glitch/styles/admin.scss
@@ -128,7 +128,6 @@ $content-width: 840px;
}
&.selected {
- background: darken($ui-base-color, 2%);
border-radius: 4px 0 0;
}
}
@@ -146,13 +145,9 @@ $content-width: 840px;
.simple-navigation-active-leaf a {
color: $primary-text-color;
- background-color: darken($ui-highlight-color, 2%);
+ background-color: $ui-highlight-color;
border-bottom: 0;
border-radius: 0;
-
- &:hover {
- background-color: $ui-highlight-color;
- }
}
}
@@ -246,12 +241,6 @@ $content-width: 840px;
font-weight: 700;
color: $primary-text-color;
background: $ui-highlight-color;
-
- &:hover,
- &:focus,
- &:active {
- background: lighten($ui-highlight-color, 4%);
- }
}
}
}
diff --git a/app/javascript/flavours/glitch/styles/components/misc.scss b/app/javascript/flavours/glitch/styles/components/misc.scss
index 208204021a..c8c227e0cb 100644
--- a/app/javascript/flavours/glitch/styles/components/misc.scss
+++ b/app/javascript/flavours/glitch/styles/components/misc.scss
@@ -38,11 +38,11 @@
}
.button {
- background-color: darken($ui-highlight-color, 3%);
+ background-color: $ui-button-background-color;
border: 10px none;
border-radius: 4px;
box-sizing: border-box;
- color: $primary-text-color;
+ color: $ui-button-color;
cursor: pointer;
display: inline-block;
font-family: inherit;
@@ -62,14 +62,14 @@
&:active,
&:focus,
&:hover {
- background-color: $ui-highlight-color;
+ background-color: $ui-button-focus-background-color;
}
&--destructive {
&:active,
&:focus,
&:hover {
- background-color: $error-red;
+ background-color: $ui-button-destructive-focus-background-color;
transition: none;
}
}
@@ -79,43 +79,22 @@
cursor: default;
}
- &.button-alternative {
- color: $inverted-text-color;
- background: $ui-primary-color;
-
- &:active,
- &:focus,
- &:hover {
- background-color: lighten($ui-primary-color, 4%);
- }
- }
-
- &.button-alternative-2 {
- background: $ui-base-lighter-color;
-
- &:active,
- &:focus,
- &:hover {
- background-color: lighten($ui-base-lighter-color, 4%);
- }
- }
-
&.button-secondary {
font-size: 16px;
line-height: 36px;
height: auto;
- color: $darker-text-color;
+ color: $ui-button-secondary-color;
text-transform: none;
background: transparent;
padding: 6px 17px;
- border: 1px solid lighten($ui-base-color, 12%);
+ border: 1px solid $ui-button-secondary-border-color;
&:active,
&:focus,
&:hover {
- background: lighten($ui-base-color, 4%);
- border-color: lighten($ui-base-color, 16%);
- color: lighten($darker-text-color, 4%);
+ border-color: $ui-button-secondary-focus-background-color;
+ color: $ui-button-secondary-focus-color;
+ background-color: $ui-button-secondary-focus-background-color;
text-decoration: none;
}
@@ -127,14 +106,14 @@
&.button-tertiary {
background: transparent;
padding: 6px 17px;
- color: $highlight-text-color;
- border: 1px solid $highlight-text-color;
+ color: $ui-button-tertiary-color;
+ border: 1px solid $ui-button-tertiary-border-color;
&:active,
&:focus,
&:hover {
- background: $ui-highlight-color;
- color: $primary-text-color;
+ background-color: $ui-button-tertiary-focus-background-color;
+ color: $ui-button-tertiary-focus-color;
border: 0;
padding: 7px 18px;
}
diff --git a/app/javascript/flavours/glitch/styles/components/modal.scss b/app/javascript/flavours/glitch/styles/components/modal.scss
index c68c5fc53d..9c4149fb95 100644
--- a/app/javascript/flavours/glitch/styles/components/modal.scss
+++ b/app/javascript/flavours/glitch/styles/components/modal.scss
@@ -718,15 +718,15 @@
}
.button.button-secondary {
- border-color: $inverted-text-color;
- color: $inverted-text-color;
+ border-color: $ui-button-secondary-border-color;
+ color: $ui-button-secondary-color;
flex: 0 0 auto;
&:hover,
&:focus,
&:active {
- border-color: lighten($inverted-text-color, 15%);
- color: lighten($inverted-text-color, 15%);
+ border-color: $ui-button-secondary-focus-background-color;
+ color: $ui-button-secondary-focus-color;
}
}
diff --git a/app/javascript/flavours/glitch/styles/dashboard.scss b/app/javascript/flavours/glitch/styles/dashboard.scss
index bc34c6ec0a..36a7f44253 100644
--- a/app/javascript/flavours/glitch/styles/dashboard.scss
+++ b/app/javascript/flavours/glitch/styles/dashboard.scss
@@ -81,7 +81,7 @@
display: flex;
align-items: baseline;
border-radius: 4px;
- background: darken($ui-highlight-color, 2%);
+ background: $ui-button-background-color;
color: $primary-text-color;
transition: all 100ms ease-in;
font-size: 14px;
@@ -94,7 +94,7 @@
&:active,
&:focus,
&:hover {
- background-color: $ui-highlight-color;
+ background-color: $ui-button-focus-background-color;
transition: all 200ms ease-out;
}
diff --git a/app/javascript/flavours/glitch/styles/forms.scss b/app/javascript/flavours/glitch/styles/forms.scss
index 81f42af145..850374f613 100644
--- a/app/javascript/flavours/glitch/styles/forms.scss
+++ b/app/javascript/flavours/glitch/styles/forms.scss
@@ -512,8 +512,8 @@ code {
width: 100%;
border: 0;
border-radius: 4px;
- background: darken($ui-highlight-color, 2%);
- color: $primary-text-color;
+ background: $ui-button-background-color;
+ color: $ui-button-color;
font-size: 18px;
line-height: inherit;
height: auto;
@@ -535,7 +535,7 @@ code {
&:active,
&:focus,
&:hover {
- background-color: $ui-highlight-color;
+ background-color: $ui-button-focus-background-color;
}
&:disabled:hover {
@@ -543,15 +543,12 @@ code {
}
&.negative {
- background: $error-value-color;
-
- &:hover {
- background-color: lighten($error-value-color, 5%);
- }
+ background: $ui-button-destructive-background-color;
+ &:hover,
&:active,
&:focus {
- background-color: darken($error-value-color, 5%);
+ background-color: $ui-button-destructive-focus-background-color;
}
}
}
diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
index cfcdd742e1..ce74e88bd9 100644
--- a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
+++ b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
@@ -5,19 +5,6 @@ html {
scrollbar-color: $ui-base-color rgba($ui-base-color, 0.25);
}
-// Change the colors of button texts
-.button {
- color: $white;
-
- &.button-alternative-2 {
- color: $white;
- }
-
- &.button-tertiary {
- color: $highlight-text-color;
- }
-}
-
.simple_form .button.button-tertiary {
color: $highlight-text-color;
}
@@ -436,26 +423,6 @@ html {
color: $white;
}
-.button.button-tertiary {
- &:hover,
- &:focus,
- &:active {
- color: $white;
- }
-}
-
-.button.button-secondary {
- border-color: $darker-text-color;
- color: $darker-text-color;
-
- &:hover,
- &:focus,
- &:active {
- border-color: darken($darker-text-color, 8%);
- color: darken($darker-text-color, 8%);
- }
-}
-
.flash-message.warning {
color: lighten($gold-star, 16%);
}
diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss b/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss
index cae065878c..250e200fc6 100644
--- a/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss
+++ b/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss
@@ -7,6 +7,12 @@ $classic-primary-color: #9baec8;
$classic-secondary-color: #d9e1e8;
$classic-highlight-color: #6364ff;
+$blurple-600: #563acc; // Iris
+$blurple-500: #6364ff; // Brand purple
+$blurple-300: #858afa; // Faded Blue
+$grey-600: #4e4c5a; // Trout
+$grey-100: #dadaf3; // Topaz
+
// Differences
$success-green: lighten(#3c754d, 8%);
@@ -19,6 +25,13 @@ $ui-primary-color: #9bcbed;
$ui-secondary-color: $classic-base-color !default;
$ui-highlight-color: $classic-highlight-color !default;
+$ui-button-secondary-color: $grey-600 !default;
+$ui-button-secondary-border-color: $grey-600 !default;
+$ui-button-secondary-focus-color: $white !default;
+
+$ui-button-tertiary-color: $blurple-500 !default;
+$ui-button-tertiary-border-color: $blurple-500 !default;
+
$primary-text-color: $black !default;
$darker-text-color: $classic-base-color !default;
$highlight-text-color: darken($ui-highlight-color, 8%) !default;
diff --git a/app/javascript/flavours/glitch/styles/variables.scss b/app/javascript/flavours/glitch/styles/variables.scss
index 8608fec723..8924e43113 100644
--- a/app/javascript/flavours/glitch/styles/variables.scss
+++ b/app/javascript/flavours/glitch/styles/variables.scss
@@ -1,10 +1,18 @@
// Commonly used web colors
$black: #000000; // Black
$white: #ffffff; // White
-$success-green: #79bd9a; // Padua
-$error-red: #df405a; // Cerise
-$warning-red: #ff5050; // Sunset Orange
-$gold-star: #ca8f04; // Dark Goldenrod
+$red-600: #b7253d !default; // Deep Carmine
+$red-500: #df405a !default; // Cerise
+$blurple-600: #563acc; // Iris
+$blurple-500: #6364ff; // Brand purple
+$blurple-300: #858afa; // Faded Blue
+$grey-600: #4e4c5a; // Trout
+$grey-100: #dadaf3; // Topaz
+
+$success-green: #79bd9a !default; // Padua
+$error-red: $red-500 !default; // Cerise
+$warning-red: #ff5050 !default; // Sunset Orange
+$gold-star: #ca8f04 !default; // Dark Goldenrod
$red-bookmark: $warning-red;
@@ -31,6 +39,22 @@ $ui-base-lighter-color: lighten(
$ui-primary-color: $classic-primary-color !default; // Lighter
$ui-secondary-color: $classic-secondary-color !default; // Lightest
$ui-highlight-color: $classic-highlight-color !default;
+$ui-button-color: $white !default;
+$ui-button-background-color: $blurple-500 !default;
+$ui-button-focus-background-color: $blurple-600 !default;
+
+$ui-button-secondary-color: $grey-100 !default;
+$ui-button-secondary-border-color: $grey-100 !default;
+$ui-button-secondary-focus-background-color: $grey-600 !default;
+$ui-button-secondary-focus-color: $white !default;
+
+$ui-button-tertiary-color: $blurple-300 !default;
+$ui-button-tertiary-border-color: $blurple-300 !default;
+$ui-button-tertiary-focus-background-color: $blurple-600 !default;
+$ui-button-tertiary-focus-color: $white !default;
+
+$ui-button-destructive-background-color: $red-500 !default;
+$ui-button-destructive-focus-background-color: $red-600 !default;
// Variables for texts
$primary-text-color: $white !default;
@@ -39,6 +63,7 @@ $dark-text-color: $ui-base-lighter-color !default;
$secondary-text-color: $ui-secondary-color !default;
$highlight-text-color: lighten($ui-highlight-color, 8%) !default;
$action-button-color: $ui-base-lighter-color !default;
+$action-button-focus-color: lighten($ui-base-lighter-color, 4%) !default;
$passive-text-color: $gold-star !default;
$active-passive-text-color: $success-green !default;
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/components/poll.jsx b/app/javascript/mastodon/components/poll.jsx
index dfc4034fa3..4304f9acd4 100644
--- a/app/javascript/mastodon/components/poll.jsx
+++ b/app/javascript/mastodon/components/poll.jsx
@@ -130,6 +130,10 @@ class Poll extends ImmutablePureComponent {
this.props.refresh();
};
+ handleReveal = () => {
+ this.setState({ revealed: true });
+ }
+
renderOption (option, optionIndex, showResults) {
const { poll, lang, disabled, intl } = this.props;
const pollVotesCount = poll.get('voters_count') || poll.get('votes_count');
@@ -205,14 +209,14 @@ class Poll extends ImmutablePureComponent {
render () {
const { poll, intl } = this.props;
- const { expired } = this.state;
+ const { revealed, expired } = this.state;
if (!poll) {
return null;
}
const timeRemaining = expired ? intl.formatMessage(messages.closed) : ;
- const showResults = poll.get('voted') || expired;
+ const showResults = poll.get('voted') || revealed || expired;
const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item);
let votesCount = null;
@@ -231,9 +235,10 @@ class Poll extends ImmutablePureComponent {
{!showResults && }
- {showResults && !this.props.disabled && · }
+ {!showResults && <> · >}
+ {showResults && !this.props.disabled && <> · >}
{votesCount}
- {poll.get('expires_at') && · {timeRemaining}}
+ {poll.get('expires_at') && <> · {timeRemaining}>}
);
diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx
index 3b3a191d6c..688a456319 100644
--- a/app/javascript/mastodon/components/status_content.jsx
+++ b/app/javascript/mastodon/components/status_content.jsx
@@ -104,7 +104,7 @@ class StatusContent extends PureComponent {
if (mention) {
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
- link.setAttribute('title', mention.get('acct'));
+ link.setAttribute('title', `@${mention.get('acct')}`);
link.setAttribute('href', `/@${mention.get('acct')}`);
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
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/account/components/header.jsx b/app/javascript/mastodon/features/account/components/header.jsx
index 5eea1abf04..b718e860d0 100644
--- a/app/javascript/mastodon/features/account/components/header.jsx
+++ b/app/javascript/mastodon/features/account/components/header.jsx
@@ -476,6 +476,7 @@ class Header extends ImmutablePureComponent {
{titleFromAccount(account)}
+
);
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..9ba4fd5b2b
--- /dev/null
+++ b/app/javascript/mastodon/features/firehose/index.jsx
@@ -0,0 +1,211 @@
+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({ maxId, 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/public_timeline/index.jsx b/app/javascript/mastodon/features/public_timeline/index.jsx
index d77b76a63e..352baa8336 100644
--- a/app/javascript/mastodon/features/public_timeline/index.jsx
+++ b/app/javascript/mastodon/features/public_timeline/index.jsx
@@ -8,6 +8,7 @@ import { Helmet } from 'react-helmet';
import { connect } from 'react-redux';
import DismissableBanner from 'mastodon/components/dismissable_banner';
+import { domain } from 'mastodon/initial_state';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { connectPublicStream } from '../../actions/streaming';
@@ -143,7 +144,7 @@ class PublicTimeline extends PureComponent {
}
+ prepend={}
timelineId={`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`}
onLoadMore={this.handleLoadMore}
trackScroll={!pinned}
diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx
index 4f1dd5a468..cdb741ea3b 100644
--- a/app/javascript/mastodon/features/status/index.jsx
+++ b/app/javascript/mastodon/features/status/index.jsx
@@ -733,6 +733,7 @@ class Status extends ImmutablePureComponent {
{titleFromStatus(intl, status)}
+
);
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 f42dc4f665..325cd76272 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",
@@ -201,7 +202,7 @@
"dismissable_banner.explore_links": "These are news stories being shared the most on the social web today. Newer news stories posted by more different people are ranked higher.",
"dismissable_banner.explore_statuses": "These are posts from across the social web that are gaining traction today. Newer posts with more boosts and favourites are ranked higher.",
"dismissable_banner.explore_tags": "These are hashtags that are gaining traction on the social web today. Hashtags that are used by more different people are ranked higher.",
- "dismissable_banner.public_timeline": "These are the most recent public posts from people on this and other servers of the decentralized network that this server knows about.",
+ "dismissable_banner.public_timeline": "These are the most recent public posts from people on the social web that people on {domain} follow.",
"embed.instructions": "Embed this post on your website by copying the code below.",
"embed.preview": "Here is what it will look like:",
"emoji_button.activity": "Activity",
@@ -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": "This server",
+ "firehose.remote": "Other servers",
"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.",
@@ -485,6 +489,7 @@
"picture_in_picture.restore": "Put it back",
"poll.closed": "Closed",
"poll.refresh": "Refresh",
+ "poll.reveal": "See results",
"poll.total_people": "{count, plural, one {# person} other {# people}}",
"poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
"poll.vote": "Vote",
@@ -652,9 +657,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 b3a88c421d..1f852ea1b3 100644
--- a/app/javascript/mastodon/reducers/settings.js
+++ b/app/javascript/mastodon/reducers/settings.js
@@ -82,6 +82,10 @@ const initialState = ImmutableMap({
}),
}),
+ firehose: ImmutableMap({
+ onlyMedia: false,
+ }),
+
community: ImmutableMap({
regex: ImmutableMap({
body: '',
diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss
index 91828d408a..9f33a5c9cc 100644
--- a/app/javascript/styles/mastodon-light/diff.scss
+++ b/app/javascript/styles/mastodon-light/diff.scss
@@ -5,19 +5,6 @@ html {
scrollbar-color: $ui-base-color rgba($ui-base-color, 0.25);
}
-// Change the colors of button texts
-.button {
- color: $white;
-
- &.button-alternative-2 {
- color: $white;
- }
-
- &.button-tertiary {
- color: $highlight-text-color;
- }
-}
-
.simple_form .button.button-tertiary {
color: $highlight-text-color;
}
@@ -436,26 +423,6 @@ html {
color: $white;
}
-.button.button-tertiary {
- &:hover,
- &:focus,
- &:active {
- color: $white;
- }
-}
-
-.button.button-secondary {
- border-color: $darker-text-color;
- color: $darker-text-color;
-
- &:hover,
- &:focus,
- &:active {
- border-color: darken($darker-text-color, 8%);
- color: darken($darker-text-color, 8%);
- }
-}
-
.flash-message.warning {
color: lighten($gold-star, 16%);
}
diff --git a/app/javascript/styles/mastodon-light/variables.scss b/app/javascript/styles/mastodon-light/variables.scss
index cae065878c..250e200fc6 100644
--- a/app/javascript/styles/mastodon-light/variables.scss
+++ b/app/javascript/styles/mastodon-light/variables.scss
@@ -7,6 +7,12 @@ $classic-primary-color: #9baec8;
$classic-secondary-color: #d9e1e8;
$classic-highlight-color: #6364ff;
+$blurple-600: #563acc; // Iris
+$blurple-500: #6364ff; // Brand purple
+$blurple-300: #858afa; // Faded Blue
+$grey-600: #4e4c5a; // Trout
+$grey-100: #dadaf3; // Topaz
+
// Differences
$success-green: lighten(#3c754d, 8%);
@@ -19,6 +25,13 @@ $ui-primary-color: #9bcbed;
$ui-secondary-color: $classic-base-color !default;
$ui-highlight-color: $classic-highlight-color !default;
+$ui-button-secondary-color: $grey-600 !default;
+$ui-button-secondary-border-color: $grey-600 !default;
+$ui-button-secondary-focus-color: $white !default;
+
+$ui-button-tertiary-color: $blurple-500 !default;
+$ui-button-tertiary-border-color: $blurple-500 !default;
+
$primary-text-color: $black !default;
$darker-text-color: $classic-base-color !default;
$highlight-text-color: darken($ui-highlight-color, 8%) !default;
diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss
index f4dfe55607..6bfb23a46f 100644
--- a/app/javascript/styles/mastodon/admin.scss
+++ b/app/javascript/styles/mastodon/admin.scss
@@ -128,7 +128,6 @@ $content-width: 840px;
}
&.selected {
- background: darken($ui-base-color, 2%);
border-radius: 4px 0 0;
}
}
@@ -146,13 +145,9 @@ $content-width: 840px;
.simple-navigation-active-leaf a {
color: $primary-text-color;
- background-color: darken($ui-highlight-color, 2%);
+ background-color: $ui-highlight-color;
border-bottom: 0;
border-radius: 0;
-
- &:hover {
- background-color: $ui-highlight-color;
- }
}
}
@@ -246,12 +241,6 @@ $content-width: 840px;
font-weight: 700;
color: $primary-text-color;
background: $ui-highlight-color;
-
- &:hover,
- &:focus,
- &:active {
- background: lighten($ui-highlight-color, 4%);
- }
}
}
}
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index c3707d1f8d..dacb620ac4 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -47,11 +47,11 @@
}
.button {
- background-color: darken($ui-highlight-color, 2%);
+ background-color: $ui-button-background-color;
border: 10px none;
border-radius: 4px;
box-sizing: border-box;
- color: $primary-text-color;
+ color: $ui-button-color;
cursor: pointer;
display: inline-block;
font-family: inherit;
@@ -71,14 +71,14 @@
&:active,
&:focus,
&:hover {
- background-color: $ui-highlight-color;
+ background-color: $ui-button-focus-background-color;
}
&--destructive {
&:active,
&:focus,
&:hover {
- background-color: $error-red;
+ background-color: $ui-button-destructive-focus-background-color;
transition: none;
}
}
@@ -108,39 +108,18 @@
outline: 0 !important;
}
- &.button-alternative {
- color: $inverted-text-color;
- background: $ui-primary-color;
-
- &:active,
- &:focus,
- &:hover {
- background-color: lighten($ui-primary-color, 4%);
- }
- }
-
- &.button-alternative-2 {
- background: $ui-base-lighter-color;
-
- &:active,
- &:focus,
- &:hover {
- background-color: lighten($ui-base-lighter-color, 4%);
- }
- }
-
&.button-secondary {
- color: $darker-text-color;
+ color: $ui-button-secondary-color;
background: transparent;
padding: 6px 17px;
- border: 1px solid lighten($ui-base-color, 12%);
+ border: 1px solid $ui-button-secondary-border-color;
&:active,
&:focus,
&:hover {
- background: lighten($ui-base-color, 4%);
- border-color: lighten($ui-base-color, 16%);
- color: lighten($darker-text-color, 4%);
+ border-color: $ui-button-secondary-focus-background-color;
+ color: $ui-button-secondary-focus-color;
+ background-color: $ui-button-secondary-focus-background-color;
text-decoration: none;
}
@@ -152,14 +131,14 @@
&.button-tertiary {
background: transparent;
padding: 6px 17px;
- color: $highlight-text-color;
- border: 1px solid $highlight-text-color;
+ color: $ui-button-tertiary-color;
+ border: 1px solid $ui-button-tertiary-border-color;
&:active,
&:focus,
&:hover {
- background: $ui-highlight-color;
- color: $primary-text-color;
+ background-color: $ui-button-tertiary-focus-background-color;
+ color: $ui-button-tertiary-focus-color;
border: 0;
padding: 7px 18px;
}
@@ -1152,6 +1131,8 @@ body > [data-popper-placement] {
}
&--in-thread {
+ $thread-margin: 46px + 10px;
+
border-bottom: 0;
.status__content,
@@ -1163,8 +1144,12 @@ body > [data-popper-placement] {
.attachment-list,
.picture-in-picture-placeholder,
.status-card {
- margin-inline-start: 46px + 10px;
- width: calc(100% - (46px + 10px));
+ margin-inline-start: $thread-margin;
+ width: calc(100% - ($thread-margin));
+ }
+
+ .status__content__read-more-button {
+ margin-inline-start: $thread-margin;
}
}
@@ -5833,15 +5818,15 @@ a.status-card.compact:hover {
}
.button.button-secondary {
- border-color: $inverted-text-color;
- color: $inverted-text-color;
+ border-color: $ui-button-secondary-border-color;
+ color: $ui-button-secondary-color;
flex: 0 0 auto;
&:hover,
&:focus,
&:active {
- border-color: lighten($inverted-text-color, 15%);
- color: lighten($inverted-text-color, 15%);
+ border-color: $ui-button-secondary-focus-background-color;
+ color: $ui-button-secondary-focus-color;
}
}
diff --git a/app/javascript/styles/mastodon/dashboard.scss b/app/javascript/styles/mastodon/dashboard.scss
index bc34c6ec0a..36a7f44253 100644
--- a/app/javascript/styles/mastodon/dashboard.scss
+++ b/app/javascript/styles/mastodon/dashboard.scss
@@ -81,7 +81,7 @@
display: flex;
align-items: baseline;
border-radius: 4px;
- background: darken($ui-highlight-color, 2%);
+ background: $ui-button-background-color;
color: $primary-text-color;
transition: all 100ms ease-in;
font-size: 14px;
@@ -94,7 +94,7 @@
&:active,
&:focus,
&:hover {
- background-color: $ui-highlight-color;
+ background-color: $ui-button-focus-background-color;
transition: all 200ms ease-out;
}
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index 81a656a602..f69b699a0a 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -511,8 +511,8 @@ code {
width: 100%;
border: 0;
border-radius: 4px;
- background: darken($ui-highlight-color, 2%);
- color: $primary-text-color;
+ background: $ui-button-background-color;
+ color: $ui-button-color;
font-size: 18px;
line-height: inherit;
height: auto;
@@ -534,7 +534,7 @@ code {
&:active,
&:focus,
&:hover {
- background-color: $ui-highlight-color;
+ background-color: $ui-button-focus-background-color;
}
&:disabled:hover {
@@ -542,15 +542,12 @@ code {
}
&.negative {
- background: $error-value-color;
-
- &:hover {
- background-color: lighten($error-value-color, 5%);
- }
+ background: $ui-button-destructive-background-color;
+ &:hover,
&:active,
&:focus {
- background-color: darken($error-value-color, 5%);
+ background-color: $ui-button-destructive-focus-background-color;
}
}
}
diff --git a/app/javascript/styles/mastodon/variables.scss b/app/javascript/styles/mastodon/variables.scss
index d6dda1b3c7..68db9d5fc0 100644
--- a/app/javascript/styles/mastodon/variables.scss
+++ b/app/javascript/styles/mastodon/variables.scss
@@ -1,8 +1,16 @@
// Commonly used web colors
$black: #000000; // Black
$white: #ffffff; // White
+$red-600: #b7253d !default; // Deep Carmine
+$red-500: #df405a !default; // Cerise
+$blurple-600: #563acc; // Iris
+$blurple-500: #6364ff; // Brand purple
+$blurple-300: #858afa; // Faded Blue
+$grey-600: #4e4c5a; // Trout
+$grey-100: #dadaf3; // Topaz
+
$success-green: #79bd9a !default; // Padua
-$error-red: #df405a !default; // Cerise
+$error-red: $red-500 !default; // Cerise
$warning-red: #ff5050 !default; // Sunset Orange
$gold-star: #ca8f04 !default; // Dark Goldenrod
@@ -31,6 +39,22 @@ $ui-base-lighter-color: lighten(
$ui-primary-color: $classic-primary-color !default; // Lighter
$ui-secondary-color: $classic-secondary-color !default; // Lightest
$ui-highlight-color: $classic-highlight-color !default;
+$ui-button-color: $white !default;
+$ui-button-background-color: $blurple-500 !default;
+$ui-button-focus-background-color: $blurple-600 !default;
+
+$ui-button-secondary-color: $grey-100 !default;
+$ui-button-secondary-border-color: $grey-100 !default;
+$ui-button-secondary-focus-background-color: $grey-600 !default;
+$ui-button-secondary-focus-color: $white !default;
+
+$ui-button-tertiary-color: $blurple-300 !default;
+$ui-button-tertiary-border-color: $blurple-300 !default;
+$ui-button-tertiary-focus-background-color: $blurple-600 !default;
+$ui-button-tertiary-focus-color: $white !default;
+
+$ui-button-destructive-background-color: $red-500 !default;
+$ui-button-destructive-focus-background-color: $red-600 !default;
// Variables for texts
$primary-text-color: $white !default;
@@ -39,6 +63,7 @@ $dark-text-color: $ui-base-lighter-color !default;
$secondary-text-color: $ui-secondary-color !default;
$highlight-text-color: lighten($ui-highlight-color, 8%) !default;
$action-button-color: $ui-base-lighter-color !default;
+$action-button-focus-color: lighten($ui-base-lighter-color, 4%) !default;
$passive-text-color: $gold-star !default;
$active-passive-text-color: $success-green !default;
diff --git a/app/lib/scope_parser.rb b/app/lib/scope_parser.rb
index d268688c83..45eb3c7b93 100644
--- a/app/lib/scope_parser.rb
+++ b/app/lib/scope_parser.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class ScopeParser < Parslet::Parser
- rule(:term) { match('[a-z]').repeat(1).as(:term) }
+ rule(:term) { match('[a-z_]').repeat(1).as(:term) }
rule(:colon) { str(':') }
rule(:access) { (str('write') | str('read')).as(:access) }
rule(:namespace) { str('admin').as(:namespace) }
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..05d2d0e7ce 100644
--- a/app/services/search_service.rb
+++ b/app/services/search_service.rb
@@ -2,12 +2,13 @@
class SearchService < BaseService
def call(query, account, limit, options = {})
- @query = query&.strip
- @account = account
- @options = options
- @limit = limit.to_i
- @offset = options[:type].blank? ? 0 : options[:offset].to_i
- @resolve = options[:resolve] || false
+ @query = query&.strip
+ @account = account
+ @options = options
+ @limit = limit.to_i
+ @offset = options[:type].blank? ? 0 : options[:offset].to_i
+ @resolve = options[:resolve] || false
+ @following = options[:following] || false
default_results.tap do |results|
next if @query.blank? || @limit.zero?
@@ -30,7 +31,9 @@ class SearchService < BaseService
@account,
limit: @limit,
resolve: @resolve,
- offset: @offset
+ offset: @offset,
+ use_searchable_text: true,
+ following: @following
)
end
diff --git a/app/workers/account_deletion_worker.rb b/app/workers/account_deletion_worker.rb
index fdf013e010..b501511728 100644
--- a/app/workers/account_deletion_worker.rb
+++ b/app/workers/account_deletion_worker.rb
@@ -6,9 +6,12 @@ class AccountDeletionWorker
sidekiq_options queue: 'pull', lock: :until_executed
def perform(account_id, options = {})
+ account = Account.find(account_id)
+ return unless account.suspended?
+
reserve_username = options.with_indifferent_access.fetch(:reserve_username, true)
skip_activitypub = options.with_indifferent_access.fetch(:skip_activitypub, false)
- DeleteAccountService.new.call(Account.find(account_id), reserve_username: reserve_username, skip_activitypub: skip_activitypub, reserve_email: false)
+ DeleteAccountService.new.call(account, reserve_username: reserve_username, skip_activitypub: skip_activitypub, reserve_email: false)
rescue ActiveRecord::RecordNotFound
true
end
diff --git a/config/routes.rb b/config/routes.rb
index 7a46624ee8..2f15c4fc02 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -13,6 +13,7 @@ Rails.application.routes.draw do
/home
/public
/public/local
+ /public/remote
/conversations
/lists/(*any)
/notifications
@@ -104,8 +105,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 +164,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/migrate/20230702131023_add_superapp_index_to_applications.rb b/db/migrate/20230702131023_add_superapp_index_to_applications.rb
new file mode 100644
index 0000000000..f301127a3e
--- /dev/null
+++ b/db/migrate/20230702131023_add_superapp_index_to_applications.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class AddSuperappIndexToApplications < ActiveRecord::Migration[6.1]
+ disable_ddl_transaction!
+
+ def change
+ add_index :oauth_applications, :superapp, where: 'superapp = true', algorithm: :concurrently
+ end
+end
diff --git a/db/migrate/20230702151753_add_index_user_on_unconfirmed_email.rb b/db/migrate/20230702151753_add_index_user_on_unconfirmed_email.rb
new file mode 100644
index 0000000000..a935463eaa
--- /dev/null
+++ b/db/migrate/20230702151753_add_index_user_on_unconfirmed_email.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class AddIndexUserOnUnconfirmedEmail < ActiveRecord::Migration[6.1]
+ disable_ddl_transaction!
+
+ def change
+ add_index :users, :unconfirmed_email, where: 'unconfirmed_email IS NOT NULL', algorithm: :concurrently
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 55f836facf..95b59e7078 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_07_02_151753) 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|
@@ -699,6 +700,7 @@ ActiveRecord::Schema.define(version: 2023_06_05_085711) do
t.bigint "owner_id"
t.boolean "confidential", default: true, null: false
t.index ["owner_id", "owner_type"], name: "index_oauth_applications_on_owner_id_and_owner_type"
+ t.index ["superapp"], name: "index_oauth_applications_on_superapp", where: "(superapp = true)"
t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true
end
@@ -1110,6 +1112,7 @@ ActiveRecord::Schema.define(version: 2023_06_05_085711) do
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true, opclass: :text_pattern_ops, where: "(reset_password_token IS NOT NULL)"
t.index ["role_id"], name: "index_users_on_role_id", where: "(role_id IS NOT NULL)"
+ t.index ["unconfirmed_email"], name: "index_users_on_unconfirmed_email", where: "(unconfirmed_email IS NOT NULL)"
end
create_table "web_push_subscriptions", 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/controllers/api/v2/search_controller_spec.rb b/spec/controllers/api/v2/search_controller_spec.rb
index bfabe8cc17..a3b92fc37a 100644
--- a/spec/controllers/api/v2/search_controller_spec.rb
+++ b/spec/controllers/api/v2/search_controller_spec.rb
@@ -14,13 +14,40 @@ RSpec.describe Api::V2::SearchController do
end
describe 'GET #index' do
- before do
- get :index, params: { q: 'test' }
- end
+ let!(:bob) { Fabricate(:account, username: 'bob_test') }
+ let!(:ana) { Fabricate(:account, username: 'ana_test') }
+ let!(:tom) { Fabricate(:account, username: 'tom_test') }
+ let(:params) { { q: 'test' } }
it 'returns http success' do
+ get :index, params: params
+
expect(response).to have_http_status(200)
end
+
+ context 'when searching accounts' do
+ let(:params) { { q: 'test', type: 'accounts' } }
+
+ it 'returns all matching accounts' do
+ get :index, params: params
+
+ expect(body_as_json[:accounts].pluck(:id)).to contain_exactly(bob.id.to_s, ana.id.to_s, tom.id.to_s)
+ end
+
+ context 'with following=true' do
+ let(:params) { { q: 'test', type: 'accounts', following: 'true' } }
+
+ before do
+ user.account.follow!(ana)
+ end
+
+ it 'returns only the followed accounts' do
+ get :index, params: params
+
+ expect(body_as_json[:accounts].pluck(:id)).to contain_exactly(ana.id.to_s)
+ end
+ 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..497ec74474 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, following: false)
expect(results).to eq empty_results.merge(accounts: [account])
end
end