Merge remote-tracking branch 'upstream/main'

This commit is contained in:
Essem 2023-07-05 12:48:40 -05:00
commit 9a33572bef
No known key found for this signature in database
GPG key ID: 7D497397CC3A2A8C
76 changed files with 1107 additions and 316 deletions

View file

@ -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'

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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) : <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />;
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 {
<div className='poll__footer'>
{!showResults && <button className='button button-secondary' disabled={disabled || !this.context.identity.signedIn} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>}
{showResults && !this.props.disabled && <span><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </span>}
{!showResults && <><button className='poll__link' onClick={this.handleReveal}><FormattedMessage id='poll.reveal' defaultMessage='See results' /></button> · </>}
{showResults && !this.props.disabled && <><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </>}
{votesCount}
{poll.get('expires_at') && <span> · {timeRemaining}</span>}
{poll.get('expires_at') && <> · {timeRemaining}</>}
</div>
</div>
);

View file

@ -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('@'));

View file

@ -161,7 +161,7 @@ class About extends PureComponent {
</Section>
<Section title={intl.formatMessage(messages.rules)}>
{!isLoading && (server.get('rules').isEmpty() ? (
{!isLoading && (server.get('rules', []).isEmpty() ? (
<p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>
) : (
<ol className='rules-list'>

View file

@ -398,6 +398,7 @@ class Header extends ImmutablePureComponent {
<Helmet>
<title>{titleFromAccount(account)}</title>
<meta name='robots' content={(isLocal && isIndexable) ? 'all' : 'noindex'} />
<link rel='canonical' href={account.get('url')} />
</Helmet>
</div>
);

View file

@ -62,7 +62,7 @@ class ActionBar extends PureComponent {
return (
<div className='compose__action-bar'>
<div className='compose__action-bar-dropdown'>
<DropdownMenuContainer items={menu} icon='ellipsis-v' size={18} direction='right' />
<DropdownMenuContainer items={menu} icon='bars' size={18} direction='right' />
</div>
</div>
);

View file

@ -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 (
<div>
<div className='column-settings__row'>
<SettingToggle
settings={settings}
settingPath={['onlyMedia']}
onChange={onChange}
label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />}
/>
<SettingToggle
settings={settings}
settingPath={['allowLocalOnly']}
onChange={onChange}
label={<FormattedMessage id='firehose.column_settings.allow_local_only' defaultMessage='Show local-only posts in "All"' />}
/>
<span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span>
<SettingText
settings={settings}
settingPath={['regex', 'body']}
onChange={onChange}
label={intl.formatMessage(messages.filter_regex)}
/>
</div>
</div>
);
};
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' ? (
<DismissableBanner id='community_timeline'>
<FormattedMessage
id='dismissable_banner.community_timeline'
defaultMessage='These are the most recent public posts from people whose accounts are hosted by {domain}.'
values={{ domain }}
/>
</DismissableBanner>
) : (
<DismissableBanner id='public_timeline'>
<FormattedMessage
id='dismissable_banner.public_timeline'
defaultMessage='These are the most recent public posts from people on the social web that people on {domain} follow.'
values={{ domain }}
/>
</DismissableBanner>
);
const emptyMessage = feedType === 'community' ? (
<FormattedMessage
id='empty_column.community'
defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!'
/>
) : (
<FormattedMessage
id='empty_column.public'
defaultMessage='There is nothing here! Write something publicly, or manually follow users from other servers to fill it up'
/>
);
return (
<Column bindToDocument={!multiColumn} ref={columnRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader
icon='globe'
active={hasUnread}
title={intl.formatMessage(messages.title)}
onPin={handlePin}
onClick={handleHeaderClick}
multiColumn={multiColumn}
>
<ColumnSettings />
</ColumnHeader>
<div className='scrollable scrollable--flex'>
<div className='account__section-headline'>
<NavLink exact to='/public/local'>
<FormattedMessage tagName='div' id='firehose.local' defaultMessage='This server' />
</NavLink>
<NavLink exact to='/public/remote'>
<FormattedMessage tagName='div' id='firehose.remote' defaultMessage='Other servers' />
</NavLink>
<NavLink exact to='/public'>
<FormattedMessage tagName='div' id='firehose.all' defaultMessage='All' />
</NavLink>
</div>
<StatusListContainer
prepend={prependBanner}
timelineId={`${feedType}${feedType === 'public' && allowLocalOnly ? ':allow_local_only' : ''}${onlyMedia ? ':media' : ''}`}
onLoadMore={handleLoadMore}
trackScroll
scrollKey='firehose'
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
regex={regex}
/>
</div>
<Helmet>
<title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
Firehose.propTypes = {
multiColumn: PropTypes.bool,
feedType: PropTypes.string,
};
export default Firehose;

View file

@ -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

View file

@ -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 {
</ColumnHeader>
<StatusListContainer
prepend={<DismissableBanner id='public_timeline'><FormattedMessage id='dismissable_banner.public_timeline' defaultMessage='These are the most recent public posts from people on this and other servers of the decentralized network that this server knows about.' /></DismissableBanner>}
prepend={<DismissableBanner id='public_timeline'><FormattedMessage id='dismissable_banner.public_timeline' defaultMessage='These are the most recent public posts from people on the social web that people on {domain} follow.' values={{ domain }} /></DismissableBanner>}
timelineId={`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`}
onLoadMore={this.handleLoadMore}
trackScroll={!pinned}

View file

@ -772,6 +772,7 @@ class Status extends ImmutablePureComponent {
<Helmet>
<title>{titleFromStatus(intl, status)}</title>
<meta name='robots' content={(isLocal && isIndexable) ? 'all' : 'noindex'} />
<link rel='canonical' href={status.get('url')} />
</Helmet>
</Column>
);

View file

@ -92,7 +92,6 @@ class Header extends PureComponent {
content = (
<>
{location.pathname !== '/search' && <Link to='/search' className='button button-secondary' aria-label={intl.formatMessage(messages.search)}><Icon id='search' /></Link>}
{signupButton}
<a href='/auth/sign_in' className='button button-tertiary'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Login' /></a>
</>

View file

@ -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) && (
<>
<ColumnLink transparent to='/public/local' icon='users' text={intl.formatMessage(messages.local)} />
<ColumnLink transparent exact to='/public' icon='globe' text={intl.formatMessage(messages.federated)} />
</>
<ColumnLink transparent to='/public/local' isActive={this.isFirehoseActive} icon='globe' text={intl.formatMessage(messages.firehose)} />
)}
{!signedIn && (

View file

@ -37,8 +37,7 @@ import {
Status,
GettingStarted,
KeyboardShortcuts,
PublicTimeline,
CommunityTimeline,
Firehose,
AccountTimeline,
AccountGallery,
HomeTimeline,
@ -196,8 +195,11 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/privacy-policy' component={PrivacyPolicy} content={children} />
<WrappedRoute path={['/home', '/timelines/home']} component={HomeTimeline} content={children} />
<WrappedRoute path={['/public', '/timelines/public']} exact component={PublicTimeline} content={children} />
<WrappedRoute path={['/public/local', '/timelines/public/local']} exact component={CommunityTimeline} content={children} />
<Redirect from='/timelines/public' to='/public' exact />
<Redirect from='/timelines/public/local' to='/public/local' exact />
<WrappedRoute path='/public' exact component={Firehose} componentParams={{ feedType: 'public' }} content={children} />
<WrappedRoute path='/public/local' exact component={Firehose} componentParams={{ feedType: 'community' }} content={children} />
<WrappedRoute path='/public/remote' exact component={Firehose} componentParams={{ feedType: 'public:remote' }} content={children} />
<WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} />
<WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
<WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />

View file

@ -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');
}

View file

@ -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!",

View file

@ -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.<key>` 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 };

View file

@ -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(),
}),

View file

@ -87,6 +87,15 @@ const initialState = ImmutableMap({
}),
}),
firehose: ImmutableMap({
onlyMedia: false,
allowLocalOnly: true,
regex: ImmutableMap({
body: '',
}),
}),
community: ImmutableMap({
regex: ImmutableMap({
body: '',

View file

@ -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%);
}
}
}
}

View file

@ -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;
}

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -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;
}
}
}

View file

@ -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%);
}

View file

@ -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;

View file

@ -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;

View file

@ -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)

View file

@ -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) : <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />;
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 {
<div className='poll__footer'>
{!showResults && <button className='button button-secondary' disabled={disabled || !this.context.identity.signedIn} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>}
{showResults && !this.props.disabled && <span><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </span>}
{!showResults && <><button className='poll__link' onClick={this.handleReveal}><FormattedMessage id='poll.reveal' defaultMessage='See results' /></button> · </>}
{showResults && !this.props.disabled && <><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </>}
{votesCount}
{poll.get('expires_at') && <span> · {timeRemaining}</span>}
{poll.get('expires_at') && <> · {timeRemaining}</>}
</div>
</div>
);

View file

@ -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);

View file

@ -161,7 +161,7 @@ class About extends PureComponent {
</Section>
<Section title={intl.formatMessage(messages.rules)}>
{!isLoading && (server.get('rules').isEmpty() ? (
{!isLoading && (server.get('rules', []).isEmpty() ? (
<p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>
) : (
<ol className='rules-list'>

View file

@ -476,6 +476,7 @@ class Header extends ImmutablePureComponent {
<Helmet>
<title>{titleFromAccount(account)}</title>
<meta name='robots' content={(isLocal && isIndexable) ? 'all' : 'noindex'} />
<link rel='canonical' href={account.get('url')} />
</Helmet>
</div>
);

View file

@ -60,7 +60,7 @@ class ActionBar extends PureComponent {
return (
<div className='compose__action-bar'>
<div className='compose__action-bar-dropdown'>
<DropdownMenuContainer items={menu} icon='ellipsis-v' size={18} direction='right' />
<DropdownMenuContainer items={menu} icon='bars' size={18} direction='right' />
</div>
</div>
);

View file

@ -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 (
<div>
<div className='column-settings__row'>
<SettingToggle
settings={settings}
settingPath={['onlyMedia']}
onChange={onChange}
label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />}
/>
</div>
</div>
);
};
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' ? (
<DismissableBanner id='community_timeline'>
<FormattedMessage
id='dismissable_banner.community_timeline'
defaultMessage='These are the most recent public posts from people whose accounts are hosted by {domain}.'
values={{ domain }}
/>
</DismissableBanner>
) : (
<DismissableBanner id='public_timeline'>
<FormattedMessage
id='dismissable_banner.public_timeline'
defaultMessage='These are the most recent public posts from people on the social web that people on {domain} follow.'
values={{ domain }}
/>
</DismissableBanner>
);
const emptyMessage = feedType === 'community' ? (
<FormattedMessage
id='empty_column.community'
defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!'
/>
) : (
<FormattedMessage
id='empty_column.public'
defaultMessage='There is nothing here! Write something publicly, or manually follow users from other servers to fill it up'
/>
);
return (
<Column bindToDocument={!multiColumn} ref={columnRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader
icon='globe'
active={hasUnread}
title={intl.formatMessage(messages.title)}
onPin={handlePin}
onClick={handleHeaderClick}
multiColumn={multiColumn}
>
<ColumnSettings />
</ColumnHeader>
<div className='scrollable scrollable--flex'>
<div className='account__section-headline'>
<NavLink exact to='/public/local'>
<FormattedMessage tagName='div' id='firehose.local' defaultMessage='This server' />
</NavLink>
<NavLink exact to='/public/remote'>
<FormattedMessage tagName='div' id='firehose.remote' defaultMessage='Other servers' />
</NavLink>
<NavLink exact to='/public'>
<FormattedMessage tagName='div' id='firehose.all' defaultMessage='All' />
</NavLink>
</div>
<StatusListContainer
prepend={prependBanner}
timelineId={`${feedType}${onlyMedia ? ':media' : ''}`}
onLoadMore={handleLoadMore}
trackScroll
scrollKey='firehose'
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
/>
</div>
<Helmet>
<title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
Firehose.propTypes = {
multiColumn: PropTypes.bool,
feedType: PropTypes.string,
};
export default Firehose;

View file

@ -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

View file

@ -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 {
</ColumnHeader>
<StatusListContainer
prepend={<DismissableBanner id='public_timeline'><FormattedMessage id='dismissable_banner.public_timeline' defaultMessage='These are the most recent public posts from people on this and other servers of the decentralized network that this server knows about.' /></DismissableBanner>}
prepend={<DismissableBanner id='public_timeline'><FormattedMessage id='dismissable_banner.public_timeline' defaultMessage='These are the most recent public posts from people on the social web that people on {domain} follow.' values={{ domain }} /></DismissableBanner>}
timelineId={`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`}
onLoadMore={this.handleLoadMore}
trackScroll={!pinned}

View file

@ -733,6 +733,7 @@ class Status extends ImmutablePureComponent {
<Helmet>
<title>{titleFromStatus(intl, status)}</title>
<meta name='robots' content={(isLocal && isIndexable) ? 'all' : 'noindex'} />
<link rel='canonical' href={status.get('url')} />
</Helmet>
</Column>
);

View file

@ -91,7 +91,6 @@ class Header extends PureComponent {
content = (
<>
{location.pathname !== '/search' && <Link to='/search' className='button button-secondary' aria-label={intl.formatMessage(messages.search)}><Icon id='search' /></Link>}
{signupButton}
<a href='/auth/sign_in' className='button button-tertiary'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Login' /></a>
</>

View file

@ -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) && (
<>
<ColumnLink transparent to='/public/local' icon='users' text={intl.formatMessage(messages.local)} />
<ColumnLink transparent exact to='/public' icon='globe' text={intl.formatMessage(messages.federated)} />
</>
<ColumnLink transparent to='/public/local' isActive={this.isFirehoseActive} icon='globe' text={intl.formatMessage(messages.firehose)} />
)}
{!signedIn && (

View file

@ -36,8 +36,7 @@ import {
Status,
GettingStarted,
KeyboardShortcuts,
PublicTimeline,
CommunityTimeline,
Firehose,
AccountTimeline,
AccountGallery,
HomeTimeline,
@ -188,8 +187,11 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/privacy-policy' component={PrivacyPolicy} content={children} />
<WrappedRoute path={['/home', '/timelines/home']} component={HomeTimeline} content={children} />
<WrappedRoute path={['/public', '/timelines/public']} exact component={PublicTimeline} content={children} />
<WrappedRoute path={['/public/local', '/timelines/public/local']} exact component={CommunityTimeline} content={children} />
<Redirect from='/timelines/public' to='/public' exact />
<Redirect from='/timelines/public/local' to='/public/local' exact />
<WrappedRoute path='/public' exact component={Firehose} componentParams={{ feedType: 'public' }} content={children} />
<WrappedRoute path='/public/local' exact component={Firehose} componentParams={{ feedType: 'community' }} content={children} />
<WrappedRoute path='/public/remote' exact component={Firehose} componentParams={{ feedType: 'public:remote' }} content={children} />
<WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} />
<WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
<WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />

View file

@ -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');
}

View file

@ -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",

View file

@ -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.<key>` 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 };

View file

@ -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(),
}),

View file

@ -82,6 +82,10 @@ const initialState = ImmutableMap({
}),
}),
firehose: ImmutableMap({
onlyMedia: false,
}),
community: ImmutableMap({
regex: ImmutableMap({
body: '',

View file

@ -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%);
}

View file

@ -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;

View file

@ -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%);
}
}
}
}

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -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;
}
}
}

View file

@ -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;

View file

@ -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) }

View file

@ -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)) }

View file

@ -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)

View file

@ -9,7 +9,6 @@ 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
@ -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

View file

@ -89,13 +89,28 @@ class ResolveURLService < BaseService
def process_local_url
recognized_params = Rails.application.routes.recognize_path(@url)
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

View file

@ -8,6 +8,7 @@ class SearchService < BaseService
@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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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|

View file

@ -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
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'
)
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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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