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' source 'https://rubygems.org'
ruby '>= 3.0.0' ruby '>= 3.0.0'
gem 'pkg-config', '~> 1.5'
gem 'puma', '~> 6.3' gem 'puma', '~> 6.3'
gem 'rails', '~> 6.1.7' gem 'rails', '~> 6.1.7'
gem 'sprockets', '~> 3.7.2' gem 'sprockets', '~> 3.7.2'

View file

@ -478,7 +478,6 @@ GEM
pg (1.5.3) pg (1.5.3)
pghero (3.3.3) pghero (3.3.3)
activerecord (>= 6) activerecord (>= 6)
pkg-config (1.5.1)
posix-spawn (0.3.15) posix-spawn (0.3.15)
premailer (1.21.0) premailer (1.21.0)
addressable addressable
@ -717,7 +716,7 @@ GEM
unf_ext unf_ext
unf_ext (0.0.8.2) unf_ext (0.0.8.2)
unicode-display_width (2.4.2) unicode-display_width (2.4.2)
uri (0.12.1) uri (0.12.2)
validate_email (0.1.6) validate_email (0.1.6)
activemodel (>= 3.0) activemodel (>= 3.0)
mail (>= 2.2.5) mail (>= 2.2.5)
@ -833,7 +832,6 @@ DEPENDENCIES
parslet parslet
pg (~> 1.5) pg (~> 1.5)
pghero pghero
pkg-config (~> 1.5)
posix-spawn posix-spawn
premailer-rails premailer-rails
private_address_check (~> 0.5) private_address_check (~> 0.5)

View file

@ -2,8 +2,37 @@
class AccountsIndex < Chewy::Index class AccountsIndex < Chewy::Index
settings index: { refresh_interval: '30s' }, analysis: { 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: { analyzer: {
content: { natural: {
tokenizer: 'uax_url_email',
filter: %w(
english_possessive_stemmer
lowercase
asciifolding
cjk_width
english_stop
english_stemmer
),
},
verbatim: {
tokenizer: 'whitespace', tokenizer: 'whitespace',
filter: %w(lowercase asciifolding cjk_width), filter: %w(lowercase asciifolding cjk_width),
}, },
@ -26,18 +55,13 @@ class AccountsIndex < Chewy::Index
index_scope ::Account.searchable.includes(:account_stat) index_scope ::Account.searchable.includes(:account_stat)
root date_detection: false do root date_detection: false do
field :id, type: 'long' field(:id, type: 'long')
field(:following_count, type: 'long')
field :display_name, type: 'text', analyzer: 'content' do field(:followers_count, type: 'long')
field :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content' field(:properties, type: 'keyword', value: ->(account) { account.searchable_properties })
end 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 :acct, type: 'text', analyzer: 'content', value: ->(account) { [account.username, account.domain].compact.join('@') } do 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 :edge_ngram, type: 'text', analyzer: 'edge_ngram', search_analyzer: 'content' field(:text, type: 'text', value: ->(account) { account.searchable_text }) { field :stemmed, type: 'text', analyzer: 'natural' }
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 }
end end
end end

View file

@ -21,11 +21,35 @@ class Api::V1::DirectoriesController < Api::BaseController
def accounts_scope def accounts_scope
Account.discoverable.tap do |scope| Account.discoverable.tap do |scope|
scope.merge!(Account.local) if truthy_param?(:local) scope.merge!(account_order_scope)
scope.merge!(Account.by_recent_status) if params[:order].blank? || params[:order] == 'active' scope.merge!(local_account_scope) if local_accounts?
scope.merge!(Account.order(id: :desc)) if params[:order] == 'new' scope.merge!(account_exclusion_scope) if current_account
scope.merge!(Account.not_excluded_by_account(current_account)) if current_account scope.merge!(account_domain_block_scope) if current_account && !local_accounts?
scope.merge!(Account.not_domain_blocked_by_account(current_account)) if current_account && !truthy_param?(:local)
end end
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 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 -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :check
before_action :require_user_owned_by_application!, except: :check before_action :require_user_owned_by_application!, except: :check
before_action :require_user_not_confirmed!, except: :check before_action :require_user_not_confirmed!, except: :check
before_action :require_authenticated_user!, only: :check
def create def create
current_user.update!(email: params[:email]) if params.key?(:email) current_user.update!(email: params[:email]) if params.key?(:email)

View file

@ -34,11 +34,11 @@ class Api::V2::SearchController < Api::BaseController
params[:q], params[:q],
current_account, current_account,
limit_param(RESULTS_LIMIT), 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 end
def search_params 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
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 SERVER_DOMAIN_BLOCKS_FETCH_FAIL = 'SERVER_DOMAIN_BLOCKS_FETCH_FAIL';
export const fetchServer = () => (dispatch, getState) => { export const fetchServer = () => (dispatch, getState) => {
if (getState().getIn(['server', 'server', 'isLoading'])) {
return;
}
dispatch(fetchServerRequest()); dispatch(fetchServerRequest());
api(getState) api(getState)
@ -66,6 +70,10 @@ const fetchServerTranslationLanguagesFail = error => ({
}); });
export const fetchExtendedDescription = () => (dispatch, getState) => { export const fetchExtendedDescription = () => (dispatch, getState) => {
if (getState().getIn(['server', 'extendedDescription', 'isLoading'])) {
return;
}
dispatch(fetchExtendedDescriptionRequest()); dispatch(fetchExtendedDescriptionRequest());
api(getState) api(getState)
@ -89,6 +97,10 @@ const fetchExtendedDescriptionFail = error => ({
}); });
export const fetchDomainBlocks = () => (dispatch, getState) => { export const fetchDomainBlocks = () => (dispatch, getState) => {
if (getState().getIn(['server', 'domainBlocks', 'isLoading'])) {
return;
}
dispatch(fetchDomainBlocksRequest()); dispatch(fetchDomainBlocksRequest());
api(getState) api(getState)

View file

@ -131,6 +131,10 @@ class Poll extends ImmutablePureComponent {
this.props.refresh(); this.props.refresh();
}; };
handleReveal = () => {
this.setState({ revealed: true });
}
renderOption (option, optionIndex, showResults) { renderOption (option, optionIndex, showResults) {
const { poll, lang, disabled, intl } = this.props; const { poll, lang, disabled, intl } = this.props;
const pollVotesCount = poll.get('voters_count') || poll.get('votes_count'); const pollVotesCount = poll.get('voters_count') || poll.get('votes_count');
@ -206,14 +210,14 @@ class Poll extends ImmutablePureComponent {
render () { render () {
const { poll, intl } = this.props; const { poll, intl } = this.props;
const { expired } = this.state; const { revealed, expired } = this.state;
if (!poll) { if (!poll) {
return null; return null;
} }
const timeRemaining = expired ? intl.formatMessage(messages.closed) : <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />; 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); const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item);
let votesCount = null; let votesCount = null;
@ -232,9 +236,10 @@ class Poll extends ImmutablePureComponent {
<div className='poll__footer'> <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 && <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} {votesCount}
{poll.get('expires_at') && <span> · {timeRemaining}</span>} {poll.get('expires_at') && <> · {timeRemaining}</>}
</div> </div>
</div> </div>
); );

View file

@ -163,7 +163,7 @@ class StatusContent extends PureComponent {
if (mention) { if (mention) {
link.addEventListener('click', this.onMentionClick.bind(this, mention), false); link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
link.setAttribute('title', mention.get('acct')); link.setAttribute('title', `@${mention.get('acct')}`);
if (rewriteMentions !== 'no') { if (rewriteMentions !== 'no') {
while (link.firstChild) link.removeChild(link.firstChild); while (link.firstChild) link.removeChild(link.firstChild);
link.appendChild(document.createTextNode('@')); link.appendChild(document.createTextNode('@'));

View file

@ -161,7 +161,7 @@ class About extends PureComponent {
</Section> </Section>
<Section title={intl.formatMessage(messages.rules)}> <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> <p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>
) : ( ) : (
<ol className='rules-list'> <ol className='rules-list'>

View file

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

View file

@ -62,7 +62,7 @@ class ActionBar extends PureComponent {
return ( return (
<div className='compose__action-bar'> <div className='compose__action-bar'>
<div className='compose__action-bar-dropdown'> <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>
</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'), state => state.get('statuses'),
], (statusIds, pendingStatusIds, statusMap) => { ], (statusIds, pendingStatusIds, statusMap) => {
const recentStatusIds = pendingStatusIds.size > 0 ? pendingStatusIds : statusIds; 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 oldest = new Date(statuses.getIn([statuses.size - 1, 'created_at'], 0));
const newest = new Date(statuses.getIn([0, '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 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 Column from 'flavours/glitch/components/column';
import ColumnHeader from 'flavours/glitch/components/column_header'; import ColumnHeader from 'flavours/glitch/components/column_header';
import DismissableBanner from 'flavours/glitch/components/dismissable_banner'; 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 StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
import ColumnSettingsContainer from './containers/column_settings_container'; import ColumnSettingsContainer from './containers/column_settings_container';
@ -147,7 +148,7 @@ class PublicTimeline extends PureComponent {
</ColumnHeader> </ColumnHeader>
<StatusListContainer <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' : ''}`} timelineId={`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`}
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
trackScroll={!pinned} trackScroll={!pinned}

View file

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

View file

@ -92,7 +92,6 @@ class Header extends PureComponent {
content = ( content = (
<> <>
{location.pathname !== '/search' && <Link to='/search' className='button button-secondary' aria-label={intl.formatMessage(messages.search)}><Icon id='search' /></Link>}
{signupButton} {signupButton}
<a href='/auth/sign_in' className='button button-tertiary'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Login' /></a> <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' }, home: { id: 'tabs_bar.home', defaultMessage: 'Home' },
notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' }, notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
explore: { id: 'explore.title', defaultMessage: 'Explore' }, explore: { id: 'explore.title', defaultMessage: 'Explore' },
local: { id: 'tabs_bar.local_timeline', defaultMessage: 'Local' }, firehose: { id: 'column.firehose', defaultMessage: 'Live feeds' },
federated: { id: 'tabs_bar.federated_timeline', defaultMessage: 'Federated' },
direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' }, direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }, favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' }, bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
@ -43,6 +42,10 @@ class NavigationPanel extends Component {
onOpenSettings: PropTypes.func, onOpenSettings: PropTypes.func,
}; };
isFirehoseActive = (match, location) => {
return match || location.pathname.startsWith('/public');
};
render() { render() {
const { intl, onOpenSettings } = this.props; const { intl, onOpenSettings } = this.props;
const { signedIn, disabledAccountId } = this.context.identity; const { signedIn, disabledAccountId } = this.context.identity;
@ -64,10 +67,7 @@ class NavigationPanel extends Component {
)} )}
{(signedIn || timelinePreview) && ( {(signedIn || timelinePreview) && (
<> <ColumnLink transparent to='/public/local' isActive={this.isFirehoseActive} icon='globe' text={intl.formatMessage(messages.firehose)} />
<ColumnLink transparent to='/public/local' icon='users' text={intl.formatMessage(messages.local)} />
<ColumnLink transparent exact to='/public' icon='globe' text={intl.formatMessage(messages.federated)} />
</>
)} )}
{!signedIn && ( {!signedIn && (

View file

@ -37,8 +37,7 @@ import {
Status, Status,
GettingStarted, GettingStarted,
KeyboardShortcuts, KeyboardShortcuts,
PublicTimeline, Firehose,
CommunityTimeline,
AccountTimeline, AccountTimeline,
AccountGallery, AccountGallery,
HomeTimeline, HomeTimeline,
@ -196,8 +195,11 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/privacy-policy' component={PrivacyPolicy} content={children} /> <WrappedRoute path='/privacy-policy' component={PrivacyPolicy} content={children} />
<WrappedRoute path={['/home', '/timelines/home']} component={HomeTimeline} content={children} /> <WrappedRoute path={['/home', '/timelines/home']} component={HomeTimeline} content={children} />
<WrappedRoute path={['/public', '/timelines/public']} exact component={PublicTimeline} content={children} /> <Redirect from='/timelines/public' to='/public' exact />
<WrappedRoute path={['/public/local', '/timelines/public/local']} exact component={CommunityTimeline} content={children} /> <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={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} />
<WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} /> <WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
<WrappedRoute path='/lists/:id' component={ListTimeline} 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'); 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 () { export function HashtagTimeline () {
return import(/* webpackChunkName: "flavours/glitch/async/hashtag_timeline" */'flavours/glitch/features/hashtag_timeline'); 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.", "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", "endorsed_accounts_editor.endorsed_accounts": "Featured accounts",
"favourite_modal.combo": "You can press {combo} to skip this next time", "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.done": "Done",
"follow_recommendations.heading": "Follow people you'd like to see posts from! Here are some suggestions.", "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!", "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 { loadingBarReducer } from 'react-redux-loading-bar';
import { combineReducers } from 'redux-immutable'; import { combineReducers } from 'redux-immutable';
@ -92,6 +94,22 @@ const reducers = {
followed_tags, 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 }; export { rootReducer };

View file

@ -17,15 +17,15 @@ import {
const initialState = ImmutableMap({ const initialState = ImmutableMap({
server: ImmutableMap({ server: ImmutableMap({
isLoading: true, isLoading: false,
}), }),
extendedDescription: ImmutableMap({ extendedDescription: ImmutableMap({
isLoading: true, isLoading: false,
}), }),
domainBlocks: ImmutableMap({ domainBlocks: ImmutableMap({
isLoading: true, isLoading: false,
isAvailable: true, isAvailable: true,
items: ImmutableList(), items: ImmutableList(),
}), }),

View file

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

View file

@ -128,7 +128,6 @@ $content-width: 840px;
} }
&.selected { &.selected {
background: darken($ui-base-color, 2%);
border-radius: 4px 0 0; border-radius: 4px 0 0;
} }
} }
@ -146,13 +145,9 @@ $content-width: 840px;
.simple-navigation-active-leaf a { .simple-navigation-active-leaf a {
color: $primary-text-color; color: $primary-text-color;
background-color: darken($ui-highlight-color, 2%); background-color: $ui-highlight-color;
border-bottom: 0; border-bottom: 0;
border-radius: 0; border-radius: 0;
&:hover {
background-color: $ui-highlight-color;
}
} }
} }
@ -246,12 +241,6 @@ $content-width: 840px;
font-weight: 700; font-weight: 700;
color: $primary-text-color; color: $primary-text-color;
background: $ui-highlight-color; background: $ui-highlight-color;
&:hover,
&:focus,
&:active {
background: lighten($ui-highlight-color, 4%);
}
} }
} }
} }

View file

@ -38,11 +38,11 @@
} }
.button { .button {
background-color: darken($ui-highlight-color, 3%); background-color: $ui-button-background-color;
border: 10px none; border: 10px none;
border-radius: 4px; border-radius: 4px;
box-sizing: border-box; box-sizing: border-box;
color: $primary-text-color; color: $ui-button-color;
cursor: pointer; cursor: pointer;
display: inline-block; display: inline-block;
font-family: inherit; font-family: inherit;
@ -62,14 +62,14 @@
&:active, &:active,
&:focus, &:focus,
&:hover { &:hover {
background-color: $ui-highlight-color; background-color: $ui-button-focus-background-color;
} }
&--destructive { &--destructive {
&:active, &:active,
&:focus, &:focus,
&:hover { &:hover {
background-color: $error-red; background-color: $ui-button-destructive-focus-background-color;
transition: none; transition: none;
} }
} }
@ -79,43 +79,22 @@
cursor: default; 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 { &.button-secondary {
font-size: 16px; font-size: 16px;
line-height: 36px; line-height: 36px;
height: auto; height: auto;
color: $darker-text-color; color: $ui-button-secondary-color;
text-transform: none; text-transform: none;
background: transparent; background: transparent;
padding: 6px 17px; padding: 6px 17px;
border: 1px solid lighten($ui-base-color, 12%); border: 1px solid $ui-button-secondary-border-color;
&:active, &:active,
&:focus, &:focus,
&:hover { &:hover {
background: lighten($ui-base-color, 4%); border-color: $ui-button-secondary-focus-background-color;
border-color: lighten($ui-base-color, 16%); color: $ui-button-secondary-focus-color;
color: lighten($darker-text-color, 4%); background-color: $ui-button-secondary-focus-background-color;
text-decoration: none; text-decoration: none;
} }
@ -127,14 +106,14 @@
&.button-tertiary { &.button-tertiary {
background: transparent; background: transparent;
padding: 6px 17px; padding: 6px 17px;
color: $highlight-text-color; color: $ui-button-tertiary-color;
border: 1px solid $highlight-text-color; border: 1px solid $ui-button-tertiary-border-color;
&:active, &:active,
&:focus, &:focus,
&:hover { &:hover {
background: $ui-highlight-color; background-color: $ui-button-tertiary-focus-background-color;
color: $primary-text-color; color: $ui-button-tertiary-focus-color;
border: 0; border: 0;
padding: 7px 18px; padding: 7px 18px;
} }

View file

@ -718,15 +718,15 @@
} }
.button.button-secondary { .button.button-secondary {
border-color: $inverted-text-color; border-color: $ui-button-secondary-border-color;
color: $inverted-text-color; color: $ui-button-secondary-color;
flex: 0 0 auto; flex: 0 0 auto;
&:hover, &:hover,
&:focus, &:focus,
&:active { &:active {
border-color: lighten($inverted-text-color, 15%); border-color: $ui-button-secondary-focus-background-color;
color: lighten($inverted-text-color, 15%); color: $ui-button-secondary-focus-color;
} }
} }

View file

@ -81,7 +81,7 @@
display: flex; display: flex;
align-items: baseline; align-items: baseline;
border-radius: 4px; border-radius: 4px;
background: darken($ui-highlight-color, 2%); background: $ui-button-background-color;
color: $primary-text-color; color: $primary-text-color;
transition: all 100ms ease-in; transition: all 100ms ease-in;
font-size: 14px; font-size: 14px;
@ -94,7 +94,7 @@
&:active, &:active,
&:focus, &:focus,
&:hover { &:hover {
background-color: $ui-highlight-color; background-color: $ui-button-focus-background-color;
transition: all 200ms ease-out; transition: all 200ms ease-out;
} }

View file

@ -512,8 +512,8 @@ code {
width: 100%; width: 100%;
border: 0; border: 0;
border-radius: 4px; border-radius: 4px;
background: darken($ui-highlight-color, 2%); background: $ui-button-background-color;
color: $primary-text-color; color: $ui-button-color;
font-size: 18px; font-size: 18px;
line-height: inherit; line-height: inherit;
height: auto; height: auto;
@ -535,7 +535,7 @@ code {
&:active, &:active,
&:focus, &:focus,
&:hover { &:hover {
background-color: $ui-highlight-color; background-color: $ui-button-focus-background-color;
} }
&:disabled:hover { &:disabled:hover {
@ -543,15 +543,12 @@ code {
} }
&.negative { &.negative {
background: $error-value-color; background: $ui-button-destructive-background-color;
&:hover {
background-color: lighten($error-value-color, 5%);
}
&:hover,
&:active, &:active,
&:focus { &: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); 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 { .simple_form .button.button-tertiary {
color: $highlight-text-color; color: $highlight-text-color;
} }
@ -436,26 +423,6 @@ html {
color: $white; 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 { .flash-message.warning {
color: lighten($gold-star, 16%); color: lighten($gold-star, 16%);
} }

View file

@ -7,6 +7,12 @@ $classic-primary-color: #9baec8;
$classic-secondary-color: #d9e1e8; $classic-secondary-color: #d9e1e8;
$classic-highlight-color: #6364ff; $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 // Differences
$success-green: lighten(#3c754d, 8%); $success-green: lighten(#3c754d, 8%);
@ -19,6 +25,13 @@ $ui-primary-color: #9bcbed;
$ui-secondary-color: $classic-base-color !default; $ui-secondary-color: $classic-base-color !default;
$ui-highlight-color: $classic-highlight-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; $primary-text-color: $black !default;
$darker-text-color: $classic-base-color !default; $darker-text-color: $classic-base-color !default;
$highlight-text-color: darken($ui-highlight-color, 8%) !default; $highlight-text-color: darken($ui-highlight-color, 8%) !default;

View file

@ -1,10 +1,18 @@
// Commonly used web colors // Commonly used web colors
$black: #000000; // Black $black: #000000; // Black
$white: #ffffff; // White $white: #ffffff; // White
$success-green: #79bd9a; // Padua $red-600: #b7253d !default; // Deep Carmine
$error-red: #df405a; // Cerise $red-500: #df405a !default; // Cerise
$warning-red: #ff5050; // Sunset Orange $blurple-600: #563acc; // Iris
$gold-star: #ca8f04; // Dark Goldenrod $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; $red-bookmark: $warning-red;
@ -31,6 +39,22 @@ $ui-base-lighter-color: lighten(
$ui-primary-color: $classic-primary-color !default; // Lighter $ui-primary-color: $classic-primary-color !default; // Lighter
$ui-secondary-color: $classic-secondary-color !default; // Lightest $ui-secondary-color: $classic-secondary-color !default; // Lightest
$ui-highlight-color: $classic-highlight-color !default; $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 // Variables for texts
$primary-text-color: $white !default; $primary-text-color: $white !default;
@ -39,6 +63,7 @@ $dark-text-color: $ui-base-lighter-color !default;
$secondary-text-color: $ui-secondary-color !default; $secondary-text-color: $ui-secondary-color !default;
$highlight-text-color: lighten($ui-highlight-color, 8%) !default; $highlight-text-color: lighten($ui-highlight-color, 8%) !default;
$action-button-color: $ui-base-lighter-color !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; $passive-text-color: $gold-star !default;
$active-passive-text-color: $success-green !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 SERVER_DOMAIN_BLOCKS_FETCH_FAIL = 'SERVER_DOMAIN_BLOCKS_FETCH_FAIL';
export const fetchServer = () => (dispatch, getState) => { export const fetchServer = () => (dispatch, getState) => {
if (getState().getIn(['server', 'server', 'isLoading'])) {
return;
}
dispatch(fetchServerRequest()); dispatch(fetchServerRequest());
api(getState) api(getState)
@ -66,6 +70,10 @@ const fetchServerTranslationLanguagesFail = error => ({
}); });
export const fetchExtendedDescription = () => (dispatch, getState) => { export const fetchExtendedDescription = () => (dispatch, getState) => {
if (getState().getIn(['server', 'extendedDescription', 'isLoading'])) {
return;
}
dispatch(fetchExtendedDescriptionRequest()); dispatch(fetchExtendedDescriptionRequest());
api(getState) api(getState)
@ -89,6 +97,10 @@ const fetchExtendedDescriptionFail = error => ({
}); });
export const fetchDomainBlocks = () => (dispatch, getState) => { export const fetchDomainBlocks = () => (dispatch, getState) => {
if (getState().getIn(['server', 'domainBlocks', 'isLoading'])) {
return;
}
dispatch(fetchDomainBlocksRequest()); dispatch(fetchDomainBlocksRequest());
api(getState) api(getState)

View file

@ -130,6 +130,10 @@ class Poll extends ImmutablePureComponent {
this.props.refresh(); this.props.refresh();
}; };
handleReveal = () => {
this.setState({ revealed: true });
}
renderOption (option, optionIndex, showResults) { renderOption (option, optionIndex, showResults) {
const { poll, lang, disabled, intl } = this.props; const { poll, lang, disabled, intl } = this.props;
const pollVotesCount = poll.get('voters_count') || poll.get('votes_count'); const pollVotesCount = poll.get('voters_count') || poll.get('votes_count');
@ -205,14 +209,14 @@ class Poll extends ImmutablePureComponent {
render () { render () {
const { poll, intl } = this.props; const { poll, intl } = this.props;
const { expired } = this.state; const { revealed, expired } = this.state;
if (!poll) { if (!poll) {
return null; return null;
} }
const timeRemaining = expired ? intl.formatMessage(messages.closed) : <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />; 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); const disabled = this.props.disabled || Object.entries(this.state.selected).every(item => !item);
let votesCount = null; let votesCount = null;
@ -231,9 +235,10 @@ class Poll extends ImmutablePureComponent {
<div className='poll__footer'> <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 && <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} {votesCount}
{poll.get('expires_at') && <span> · {timeRemaining}</span>} {poll.get('expires_at') && <> · {timeRemaining}</>}
</div> </div>
</div> </div>
); );

View file

@ -104,7 +104,7 @@ class StatusContent extends PureComponent {
if (mention) { if (mention) {
link.addEventListener('click', this.onMentionClick.bind(this, mention), false); 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')}`); link.setAttribute('href', `/@${mention.get('acct')}`);
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) { } 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); link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);

View file

@ -161,7 +161,7 @@ class About extends PureComponent {
</Section> </Section>
<Section title={intl.formatMessage(messages.rules)}> <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> <p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>
) : ( ) : (
<ol className='rules-list'> <ol className='rules-list'>

View file

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

View file

@ -60,7 +60,7 @@ class ActionBar extends PureComponent {
return ( return (
<div className='compose__action-bar'> <div className='compose__action-bar'>
<div className='compose__action-bar-dropdown'> <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>
</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'), state => state.get('statuses'),
], (statusIds, pendingStatusIds, statusMap) => { ], (statusIds, pendingStatusIds, statusMap) => {
const recentStatusIds = pendingStatusIds.size > 0 ? pendingStatusIds : statusIds; 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 oldest = new Date(statuses.getIn([statuses.size - 1, 'created_at'], 0));
const newest = new Date(statuses.getIn([0, '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 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 { connect } from 'react-redux';
import DismissableBanner from 'mastodon/components/dismissable_banner'; import DismissableBanner from 'mastodon/components/dismissable_banner';
import { domain } from 'mastodon/initial_state';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { connectPublicStream } from '../../actions/streaming'; import { connectPublicStream } from '../../actions/streaming';
@ -143,7 +144,7 @@ class PublicTimeline extends PureComponent {
</ColumnHeader> </ColumnHeader>
<StatusListContainer <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' : ''}`} timelineId={`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`}
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
trackScroll={!pinned} trackScroll={!pinned}

View file

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

View file

@ -91,7 +91,6 @@ class Header extends PureComponent {
content = ( content = (
<> <>
{location.pathname !== '/search' && <Link to='/search' className='button button-secondary' aria-label={intl.formatMessage(messages.search)}><Icon id='search' /></Link>}
{signupButton} {signupButton}
<a href='/auth/sign_in' className='button button-tertiary'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Login' /></a> <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' }, home: { id: 'tabs_bar.home', defaultMessage: 'Home' },
notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' }, notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
explore: { id: 'explore.title', defaultMessage: 'Explore' }, explore: { id: 'explore.title', defaultMessage: 'Explore' },
local: { id: 'tabs_bar.local_timeline', defaultMessage: 'Local' }, firehose: { id: 'column.firehose', defaultMessage: 'Live feeds' },
federated: { id: 'tabs_bar.federated_timeline', defaultMessage: 'Federated' },
direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' }, direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }, favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' }, bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
@ -43,6 +42,10 @@ class NavigationPanel extends Component {
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
isFirehoseActive = (match, location) => {
return match || location.pathname.startsWith('/public');
};
render () { render () {
const { intl } = this.props; const { intl } = this.props;
const { signedIn, disabledAccountId } = this.context.identity; const { signedIn, disabledAccountId } = this.context.identity;
@ -69,10 +72,7 @@ class NavigationPanel extends Component {
)} )}
{(signedIn || timelinePreview) && ( {(signedIn || timelinePreview) && (
<> <ColumnLink transparent to='/public/local' isActive={this.isFirehoseActive} icon='globe' text={intl.formatMessage(messages.firehose)} />
<ColumnLink transparent to='/public/local' icon='users' text={intl.formatMessage(messages.local)} />
<ColumnLink transparent exact to='/public' icon='globe' text={intl.formatMessage(messages.federated)} />
</>
)} )}
{!signedIn && ( {!signedIn && (

View file

@ -36,8 +36,7 @@ import {
Status, Status,
GettingStarted, GettingStarted,
KeyboardShortcuts, KeyboardShortcuts,
PublicTimeline, Firehose,
CommunityTimeline,
AccountTimeline, AccountTimeline,
AccountGallery, AccountGallery,
HomeTimeline, HomeTimeline,
@ -188,8 +187,11 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/privacy-policy' component={PrivacyPolicy} content={children} /> <WrappedRoute path='/privacy-policy' component={PrivacyPolicy} content={children} />
<WrappedRoute path={['/home', '/timelines/home']} component={HomeTimeline} content={children} /> <WrappedRoute path={['/home', '/timelines/home']} component={HomeTimeline} content={children} />
<WrappedRoute path={['/public', '/timelines/public']} exact component={PublicTimeline} content={children} /> <Redirect from='/timelines/public' to='/public' exact />
<WrappedRoute path={['/public/local', '/timelines/public/local']} exact component={CommunityTimeline} content={children} /> <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={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} />
<WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} /> <WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
<WrappedRoute path='/lists/:id' component={ListTimeline} 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'); return import(/* webpackChunkName: "features/community_timeline" */'../../community_timeline');
} }
export function Firehose () {
return import(/* webpackChunkName: "features/firehose" */'../../firehose');
}
export function HashtagTimeline () { export function HashtagTimeline () {
return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline'); return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline');
} }

View file

@ -114,6 +114,7 @@
"column.directory": "Browse profiles", "column.directory": "Browse profiles",
"column.domain_blocks": "Blocked domains", "column.domain_blocks": "Blocked domains",
"column.favourites": "Favourites", "column.favourites": "Favourites",
"column.firehose": "Live feeds",
"column.follow_requests": "Follow requests", "column.follow_requests": "Follow requests",
"column.home": "Home", "column.home": "Home",
"column.lists": "Lists", "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_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_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.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.instructions": "Embed this post on your website by copying the code below.",
"embed.preview": "Here is what it will look like:", "embed.preview": "Here is what it will look like:",
"emoji_button.activity": "Activity", "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.subtitle": "Use an existing category or create a new one",
"filter_modal.select_filter.title": "Filter this post", "filter_modal.select_filter.title": "Filter this post",
"filter_modal.title.status": "Filter a 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.authorize": "Authorize",
"follow_request.reject": "Reject", "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.", "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", "picture_in_picture.restore": "Put it back",
"poll.closed": "Closed", "poll.closed": "Closed",
"poll.refresh": "Refresh", "poll.refresh": "Refresh",
"poll.reveal": "See results",
"poll.total_people": "{count, plural, one {# person} other {# people}}", "poll.total_people": "{count, plural, one {# person} other {# people}}",
"poll.total_votes": "{count, plural, one {# vote} other {# votes}}", "poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
"poll.vote": "Vote", "poll.vote": "Vote",
@ -652,9 +657,7 @@
"subscribed_languages.target": "Change subscribed languages for {target}", "subscribed_languages.target": "Change subscribed languages for {target}",
"suggestions.dismiss": "Dismiss suggestion", "suggestions.dismiss": "Dismiss suggestion",
"suggestions.header": "You might be interested in…", "suggestions.header": "You might be interested in…",
"tabs_bar.federated_timeline": "Federated",
"tabs_bar.home": "Home", "tabs_bar.home": "Home",
"tabs_bar.local_timeline": "Local",
"tabs_bar.notifications": "Notifications", "tabs_bar.notifications": "Notifications",
"time_remaining.days": "{number, plural, one {# day} other {# days}} left", "time_remaining.days": "{number, plural, one {# day} other {# days}} left",
"time_remaining.hours": "{number, plural, one {# hour} other {# hours}} 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 { loadingBarReducer } from 'react-redux-loading-bar';
import { combineReducers } from 'redux-immutable'; import { combineReducers } from 'redux-immutable';
@ -88,6 +90,22 @@ const reducers = {
followed_tags, 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 }; export { rootReducer };

View file

@ -17,15 +17,15 @@ import {
const initialState = ImmutableMap({ const initialState = ImmutableMap({
server: ImmutableMap({ server: ImmutableMap({
isLoading: true, isLoading: false,
}), }),
extendedDescription: ImmutableMap({ extendedDescription: ImmutableMap({
isLoading: true, isLoading: false,
}), }),
domainBlocks: ImmutableMap({ domainBlocks: ImmutableMap({
isLoading: true, isLoading: false,
isAvailable: true, isAvailable: true,
items: ImmutableList(), items: ImmutableList(),
}), }),

View file

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

View file

@ -5,19 +5,6 @@ html {
scrollbar-color: $ui-base-color rgba($ui-base-color, 0.25); 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 { .simple_form .button.button-tertiary {
color: $highlight-text-color; color: $highlight-text-color;
} }
@ -436,26 +423,6 @@ html {
color: $white; 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 { .flash-message.warning {
color: lighten($gold-star, 16%); color: lighten($gold-star, 16%);
} }

View file

@ -7,6 +7,12 @@ $classic-primary-color: #9baec8;
$classic-secondary-color: #d9e1e8; $classic-secondary-color: #d9e1e8;
$classic-highlight-color: #6364ff; $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 // Differences
$success-green: lighten(#3c754d, 8%); $success-green: lighten(#3c754d, 8%);
@ -19,6 +25,13 @@ $ui-primary-color: #9bcbed;
$ui-secondary-color: $classic-base-color !default; $ui-secondary-color: $classic-base-color !default;
$ui-highlight-color: $classic-highlight-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; $primary-text-color: $black !default;
$darker-text-color: $classic-base-color !default; $darker-text-color: $classic-base-color !default;
$highlight-text-color: darken($ui-highlight-color, 8%) !default; $highlight-text-color: darken($ui-highlight-color, 8%) !default;

View file

@ -128,7 +128,6 @@ $content-width: 840px;
} }
&.selected { &.selected {
background: darken($ui-base-color, 2%);
border-radius: 4px 0 0; border-radius: 4px 0 0;
} }
} }
@ -146,13 +145,9 @@ $content-width: 840px;
.simple-navigation-active-leaf a { .simple-navigation-active-leaf a {
color: $primary-text-color; color: $primary-text-color;
background-color: darken($ui-highlight-color, 2%); background-color: $ui-highlight-color;
border-bottom: 0; border-bottom: 0;
border-radius: 0; border-radius: 0;
&:hover {
background-color: $ui-highlight-color;
}
} }
} }
@ -246,12 +241,6 @@ $content-width: 840px;
font-weight: 700; font-weight: 700;
color: $primary-text-color; color: $primary-text-color;
background: $ui-highlight-color; background: $ui-highlight-color;
&:hover,
&:focus,
&:active {
background: lighten($ui-highlight-color, 4%);
}
} }
} }
} }

View file

@ -47,11 +47,11 @@
} }
.button { .button {
background-color: darken($ui-highlight-color, 2%); background-color: $ui-button-background-color;
border: 10px none; border: 10px none;
border-radius: 4px; border-radius: 4px;
box-sizing: border-box; box-sizing: border-box;
color: $primary-text-color; color: $ui-button-color;
cursor: pointer; cursor: pointer;
display: inline-block; display: inline-block;
font-family: inherit; font-family: inherit;
@ -71,14 +71,14 @@
&:active, &:active,
&:focus, &:focus,
&:hover { &:hover {
background-color: $ui-highlight-color; background-color: $ui-button-focus-background-color;
} }
&--destructive { &--destructive {
&:active, &:active,
&:focus, &:focus,
&:hover { &:hover {
background-color: $error-red; background-color: $ui-button-destructive-focus-background-color;
transition: none; transition: none;
} }
} }
@ -108,39 +108,18 @@
outline: 0 !important; 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 { &.button-secondary {
color: $darker-text-color; color: $ui-button-secondary-color;
background: transparent; background: transparent;
padding: 6px 17px; padding: 6px 17px;
border: 1px solid lighten($ui-base-color, 12%); border: 1px solid $ui-button-secondary-border-color;
&:active, &:active,
&:focus, &:focus,
&:hover { &:hover {
background: lighten($ui-base-color, 4%); border-color: $ui-button-secondary-focus-background-color;
border-color: lighten($ui-base-color, 16%); color: $ui-button-secondary-focus-color;
color: lighten($darker-text-color, 4%); background-color: $ui-button-secondary-focus-background-color;
text-decoration: none; text-decoration: none;
} }
@ -152,14 +131,14 @@
&.button-tertiary { &.button-tertiary {
background: transparent; background: transparent;
padding: 6px 17px; padding: 6px 17px;
color: $highlight-text-color; color: $ui-button-tertiary-color;
border: 1px solid $highlight-text-color; border: 1px solid $ui-button-tertiary-border-color;
&:active, &:active,
&:focus, &:focus,
&:hover { &:hover {
background: $ui-highlight-color; background-color: $ui-button-tertiary-focus-background-color;
color: $primary-text-color; color: $ui-button-tertiary-focus-color;
border: 0; border: 0;
padding: 7px 18px; padding: 7px 18px;
} }
@ -1152,6 +1131,8 @@ body > [data-popper-placement] {
} }
&--in-thread { &--in-thread {
$thread-margin: 46px + 10px;
border-bottom: 0; border-bottom: 0;
.status__content, .status__content,
@ -1163,8 +1144,12 @@ body > [data-popper-placement] {
.attachment-list, .attachment-list,
.picture-in-picture-placeholder, .picture-in-picture-placeholder,
.status-card { .status-card {
margin-inline-start: 46px + 10px; margin-inline-start: $thread-margin;
width: calc(100% - (46px + 10px)); 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 { .button.button-secondary {
border-color: $inverted-text-color; border-color: $ui-button-secondary-border-color;
color: $inverted-text-color; color: $ui-button-secondary-color;
flex: 0 0 auto; flex: 0 0 auto;
&:hover, &:hover,
&:focus, &:focus,
&:active { &:active {
border-color: lighten($inverted-text-color, 15%); border-color: $ui-button-secondary-focus-background-color;
color: lighten($inverted-text-color, 15%); color: $ui-button-secondary-focus-color;
} }
} }

View file

@ -81,7 +81,7 @@
display: flex; display: flex;
align-items: baseline; align-items: baseline;
border-radius: 4px; border-radius: 4px;
background: darken($ui-highlight-color, 2%); background: $ui-button-background-color;
color: $primary-text-color; color: $primary-text-color;
transition: all 100ms ease-in; transition: all 100ms ease-in;
font-size: 14px; font-size: 14px;
@ -94,7 +94,7 @@
&:active, &:active,
&:focus, &:focus,
&:hover { &:hover {
background-color: $ui-highlight-color; background-color: $ui-button-focus-background-color;
transition: all 200ms ease-out; transition: all 200ms ease-out;
} }

View file

@ -511,8 +511,8 @@ code {
width: 100%; width: 100%;
border: 0; border: 0;
border-radius: 4px; border-radius: 4px;
background: darken($ui-highlight-color, 2%); background: $ui-button-background-color;
color: $primary-text-color; color: $ui-button-color;
font-size: 18px; font-size: 18px;
line-height: inherit; line-height: inherit;
height: auto; height: auto;
@ -534,7 +534,7 @@ code {
&:active, &:active,
&:focus, &:focus,
&:hover { &:hover {
background-color: $ui-highlight-color; background-color: $ui-button-focus-background-color;
} }
&:disabled:hover { &:disabled:hover {
@ -542,15 +542,12 @@ code {
} }
&.negative { &.negative {
background: $error-value-color; background: $ui-button-destructive-background-color;
&:hover {
background-color: lighten($error-value-color, 5%);
}
&:hover,
&:active, &:active,
&:focus { &: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 // Commonly used web colors
$black: #000000; // Black $black: #000000; // Black
$white: #ffffff; // White $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 $success-green: #79bd9a !default; // Padua
$error-red: #df405a !default; // Cerise $error-red: $red-500 !default; // Cerise
$warning-red: #ff5050 !default; // Sunset Orange $warning-red: #ff5050 !default; // Sunset Orange
$gold-star: #ca8f04 !default; // Dark Goldenrod $gold-star: #ca8f04 !default; // Dark Goldenrod
@ -31,6 +39,22 @@ $ui-base-lighter-color: lighten(
$ui-primary-color: $classic-primary-color !default; // Lighter $ui-primary-color: $classic-primary-color !default; // Lighter
$ui-secondary-color: $classic-secondary-color !default; // Lightest $ui-secondary-color: $classic-secondary-color !default; // Lightest
$ui-highlight-color: $classic-highlight-color !default; $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 // Variables for texts
$primary-text-color: $white !default; $primary-text-color: $white !default;
@ -39,6 +63,7 @@ $dark-text-color: $ui-base-lighter-color !default;
$secondary-text-color: $ui-secondary-color !default; $secondary-text-color: $ui-secondary-color !default;
$highlight-text-color: lighten($ui-highlight-color, 8%) !default; $highlight-text-color: lighten($ui-highlight-color, 8%) !default;
$action-button-color: $ui-base-lighter-color !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; $passive-text-color: $gold-star !default;
$active-passive-text-color: $success-green !default; $active-passive-text-color: $success-green !default;

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class ScopeParser < Parslet::Parser 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(:colon) { str(':') }
rule(:access) { (str('write') | str('read')).as(:access) } rule(:access) { (str('write') | str('read')).as(:access) }
rule(:namespace) { str('admin').as(:namespace) } 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_username, ->(value) { where('lower((username)::text) LIKE lower(?)', "#{value}%") }
scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) } scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) }
scope :matches_domain, ->(value) { where(arel_table[:domain].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 :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 :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)) } 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 LIMIT :limit OFFSET :offset
SQL 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 class_methods do
def search_for(terms, limit: 10, offset: 0) def search_for(terms, limit: 10, offset: 0)
tsquery = generate_query_for_search(terms) tsquery = generate_query_for_search(terms)

View file

@ -9,7 +9,6 @@ class AccountSearchService < BaseService
MIN_QUERY_LENGTH = 5 MIN_QUERY_LENGTH = 5
def call(query, account = nil, options = {}) def call(query, account = nil, options = {})
@acct_hint = query&.start_with?('@')
@query = query&.strip&.gsub(/\A@/, '') @query = query&.strip&.gsub(/\A@/, '')
@limit = options[:limit].to_i @limit = options[:limit].to_i
@offset = options[:offset].to_i @offset = options[:offset].to_i
@ -72,8 +71,8 @@ class AccountSearchService < BaseService
end end
def from_elasticsearch 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' } }] must_clauses = must_clause
should_clauses = [] should_clauses = should_clause
if account if account
return [] if options[:following] && following_ids.empty? return [] if options[:following] && following_ids.empty?
@ -88,7 +87,7 @@ class AccountSearchService < BaseService
query = { bool: { must: must_clauses, should: should_clauses } } query = { bool: { must: must_clauses, should: should_clauses } }
functions = [reputation_score_function, followers_score_function, time_distance_function] 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) .limit(limit_for_non_exact_results)
.offset(offset) .offset(offset)
.objects .objects
@ -133,6 +132,36 @@ class AccountSearchService < BaseService
} }
end 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 def following_ids
@following_ids ||= account.active_relationships.pluck(:target_account_id) + [account.id] @following_ids ||= account.active_relationships.pluck(:target_account_id) + [account.id]
end end
@ -182,8 +211,4 @@ class AccountSearchService < BaseService
def username_complete? def username_complete?
query.include?('@') && "@#{query}".match?(MENTION_ONLY_RE) query.include?('@') && "@#{query}".match?(MENTION_ONLY_RE)
end end
def likely_acct?
@acct_hint || username_complete?
end
end end

View file

@ -89,13 +89,28 @@ class ResolveURLService < BaseService
def process_local_url def process_local_url
recognized_params = Rails.application.routes.recognize_path(@url) recognized_params = Rails.application.routes.recognize_path(@url)
case recognized_params[:controller]
when 'statuses'
return unless recognized_params[:action] == 'show' return unless recognized_params[:action] == 'show'
if recognized_params[:controller] == 'statuses'
status = Status.find_by(id: recognized_params[:id]) status = Status.find_by(id: recognized_params[:id])
check_local_status(status) check_local_status(status)
elsif recognized_params[:controller] == 'accounts' when 'accounts'
return unless recognized_params[:action] == 'show'
Account.find_local(recognized_params[:username]) 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
end end

View file

@ -8,6 +8,7 @@ class SearchService < BaseService
@limit = limit.to_i @limit = limit.to_i
@offset = options[:type].blank? ? 0 : options[:offset].to_i @offset = options[:type].blank? ? 0 : options[:offset].to_i
@resolve = options[:resolve] || false @resolve = options[:resolve] || false
@following = options[:following] || false
default_results.tap do |results| default_results.tap do |results|
next if @query.blank? || @limit.zero? next if @query.blank? || @limit.zero?
@ -30,7 +31,9 @@ class SearchService < BaseService
@account, @account,
limit: @limit, limit: @limit,
resolve: @resolve, resolve: @resolve,
offset: @offset offset: @offset,
use_searchable_text: true,
following: @following
) )
end end

View file

@ -6,9 +6,12 @@ class AccountDeletionWorker
sidekiq_options queue: 'pull', lock: :until_executed sidekiq_options queue: 'pull', lock: :until_executed
def perform(account_id, options = {}) def perform(account_id, options = {})
account = Account.find(account_id)
return unless account.suspended?
reserve_username = options.with_indifferent_access.fetch(:reserve_username, true) reserve_username = options.with_indifferent_access.fetch(:reserve_username, true)
skip_activitypub = options.with_indifferent_access.fetch(:skip_activitypub, false) 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 rescue ActiveRecord::RecordNotFound
true true
end end

View file

@ -13,6 +13,7 @@ Rails.application.routes.draw do
/home /home
/public /public
/public/local /public/local
/public/remote
/conversations /conversations
/lists/(*any) /lists/(*any)
/notifications /notifications
@ -104,8 +105,6 @@ Rails.application.routes.draw do
resources :followers, only: [:index], controller: :follower_accounts resources :followers, only: [:index], controller: :follower_accounts
resources :following, only: [:index], controller: :following_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 :outbox, only: [:show], module: :activitypub
resource :inbox, only: [:create], 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 get '/backups/:id/download', to: 'backups#download', as: :download_backup, format: false
resource :authorize_interaction, only: [:show, :create] resource :authorize_interaction, only: [:show, :create]
resource :share, only: [:show, :create] resource :share, only: [:show]
draw(:admin) draw(:admin)

View file

@ -3,7 +3,7 @@
namespace :admin do namespace :admin do
get '/dashboard', to: 'dashboard#index' 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 resources :domain_blocks, only: [:new, :create, :destroy, :update, :edit] do
collection do collection do
post :batch post :batch
@ -31,7 +31,7 @@ namespace :admin do
end end
resources :action_logs, only: [:index] resources :action_logs, only: [:index]
resources :warning_presets, except: [:new] resources :warning_presets, except: [:new, :show]
resources :announcements, except: [:show] do resources :announcements, except: [:show] do
member do member do
@ -76,7 +76,7 @@ namespace :admin do
end end
end end
resources :rules resources :rules, only: [:index, :create, :edit, :update, :destroy]
resources :webhooks do resources :webhooks do
member do member do

View file

@ -1,6 +1,5 @@
skip_untranslated_strings: 1
commit_message: '[ci skip]' commit_message: '[ci skip]'
skip_untranslated_strings: true
files: files:
- source: /app/javascript/mastodon/locales/en.json - source: /app/javascript/mastodon/locales/en.json
translation: /app/javascript/mastodon/locales/%two_letters_code%.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. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -273,6 +273,7 @@ ActiveRecord::Schema.define(version: 2023_06_05_085711) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.bigint "dump_file_size" t.bigint "dump_file_size"
t.index ["user_id"], name: "index_backups_on_user_id"
end end
create_table "blocks", force: :cascade do |t| 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.bigint "owner_id"
t.boolean "confidential", default: true, null: false 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 ["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 t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true
end 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 ["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 ["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 ["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 end
create_table "web_push_subscriptions", force: :cascade do |t| create_table "web_push_subscriptions", force: :cascade do |t|

View file

@ -5,19 +5,124 @@ require 'rails_helper'
describe Api::V1::DirectoriesController do describe Api::V1::DirectoriesController do
render_views 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(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:follows') }
let(:account) { Fabricate(:account) }
before do before do
allow(controller).to receive(:doorkeeper_token) { token } allow(controller).to receive(:doorkeeper_token) { token }
end end
describe 'GET #show' do 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 get :show
expect(response).to have_http_status(200) 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 end
end end

View file

@ -130,5 +130,13 @@ RSpec.describe Api::V1::Emails::ConfirmationsController do
end end
end 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
end end

View file

@ -14,13 +14,40 @@ RSpec.describe Api::V2::SearchController do
end end
describe 'GET #index' do describe 'GET #index' do
before do let!(:bob) { Fabricate(:account, username: 'bob_test') }
get :index, params: { q: 'test' } let!(:ana) { Fabricate(:account, username: 'ana_test') }
end let!(:tom) { Fabricate(:account, username: 'tom_test') }
let(:params) { { q: 'test' } }
it 'returns http success' do it 'returns http success' do
get :index, params: params
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
end 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
end end

View file

@ -145,5 +145,35 @@ describe ResolveURLService, type: :service do
expect(subject.call(url, on_behalf_of: account)).to eq(status) expect(subject.call(url, on_behalf_of: account)).to eq(status)
end end
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
end end

View file

@ -68,7 +68,7 @@ describe SearchService, type: :service do
allow(AccountSearchService).to receive(:new).and_return(service) allow(AccountSearchService).to receive(:new).and_return(service)
results = subject.call(query, nil, 10) 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]) expect(results).to eq empty_results.merge(accounts: [account])
end end
end end